OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
78
app/Command/AbstractPluginCommand.php
Normal file
78
app/Command/AbstractPluginCommand.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Leantime\Domain\Plugins\Models\InstalledPlugin;
|
||||
use Leantime\Domain\Plugins\Services\Plugins;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Class AbstractPluginCommand
|
||||
*/
|
||||
abstract class AbstractPluginCommand extends Command
|
||||
{
|
||||
protected SymfonyStyle $io;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
protected readonly Plugins $plugins
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command
|
||||
*
|
||||
* @return int 0 if everything went fine, or an exit code.
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->input = $input;
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
|
||||
return $this->executeCommand();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual command.
|
||||
*/
|
||||
abstract protected function executeCommand(): int;
|
||||
|
||||
/**
|
||||
* Asks a confirmation question.
|
||||
*/
|
||||
public function confirm($question, $default = false): bool
|
||||
{
|
||||
return $this->io->confirm($question, ! $this->input->isInteractive());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|InstalledPlugin[]
|
||||
*/
|
||||
protected function getAllPlugins(): array
|
||||
{
|
||||
return array_values(
|
||||
array_merge(
|
||||
$this->plugins->getAllPlugins() ?: [],
|
||||
$this->plugins->discoverNewPlugins(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected function getPlugin(string $name): InstalledPlugin
|
||||
{
|
||||
foreach ($this->getAllPlugins() as $plugin) {
|
||||
if ($name === $plugin->name) {
|
||||
return $plugin;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf('Invalid plugin name: %s', $name));
|
||||
}
|
||||
}
|
||||
146
app/Command/AddUserCommand.php
Normal file
146
app/Command/AddUserCommand.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Clients\Repositories\Clients;
|
||||
use Leantime\Domain\Users\Repositories\Users;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Class AddUserCommand
|
||||
*
|
||||
* This command adds a new user.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'user:add',
|
||||
description: 'Add a new user (email, password, role required options)',
|
||||
)]
|
||||
class AddUserCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this->addOption(name: 'email', mode: InputOption::VALUE_REQUIRED, description: "User's Email")
|
||||
->addOption(name: 'password', mode: InputOption::VALUE_REQUIRED, description: "User's Password")
|
||||
->addOption(
|
||||
'role',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
"User's Role",
|
||||
array_values(Roles::getRoles()),
|
||||
)
|
||||
->addOption(name: 'client-id', mode: InputOption::VALUE_OPTIONAL, description: 'Id of The Client to Assign the User To')
|
||||
->addOption(name: 'first-name', mode: InputOption::VALUE_OPTIONAL, description: "User's First name")
|
||||
->addOption(name: 'last-name', mode: InputOption::VALUE_OPTIONAL, description: "User's Last Name")
|
||||
->addOption(name: 'phone', mode: InputOption::VALUE_OPTIONAL, description: "User's Phone");
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command
|
||||
*
|
||||
*
|
||||
* @return int 0 if everything went fine, or an exit code.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
! defined('BASE_URL') && define('BASE_URL', '');
|
||||
! defined('CURRENT_URL') && define('CURRENT_URL', '');
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$email = $input->getOption('email');
|
||||
if ($email === null) {
|
||||
$io->error('Email is Required "--email"');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$io->error('Email is Invalid');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$password = $input->getOption('password');
|
||||
if ($password === null) {
|
||||
$io->error('Password is Required "--password"');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$role = $input->getOption('role');
|
||||
if ($role === null) {
|
||||
$io->error('Role is Required "--role"');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
if (! in_array($role, array_values(Roles::getRoles()))) {
|
||||
$io->error('Role is Invalid');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$clientId = $input->getOption('client-id');
|
||||
if ($clientId === null) {
|
||||
$clientsRepository = app()->make(Clients::class);
|
||||
$clients = $clientsRepository->getAll();
|
||||
if (count($clients) < 1) {
|
||||
$io->error('No clients found, cannot add user');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$clientId = $clients[0]['id'];
|
||||
}
|
||||
|
||||
$firstName = $input->getOption('first-name');
|
||||
$lastName = $input->getOption('last-name');
|
||||
$phone = $input->getOption('phone');
|
||||
|
||||
$user = [
|
||||
'user' => $email,
|
||||
'password' => $password,
|
||||
'role' => array_search($role, Roles::getRoles()),
|
||||
'clientId' => $clientId,
|
||||
'firstname' => $firstName,
|
||||
'lastname' => $lastName,
|
||||
'phone' => $phone,
|
||||
'status' => 'A',
|
||||
];
|
||||
|
||||
try {
|
||||
$usersRepo = app()->make(Users::class);
|
||||
|
||||
if ($usersRepo->usernameExist($email)) {
|
||||
$io->error('User Already Exists');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$userId = $usersRepo->addUser($user);
|
||||
if (! $userId) {
|
||||
$io->error('Failed to Add User');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success('User created successfully');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
209
app/Command/BackfillGoalHistoryCommand.php
Normal file
209
app/Command/BackfillGoalHistoryCommand.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvasRepo;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Import historical goal values from a CSV so a program three years into a
|
||||
* grant doesn't have to wait until year 5 to see an arc. Rows land in
|
||||
* `zp_goal_history` in exactly the same shape as the nightly capture
|
||||
* (`itemId`, `value`, `userId`, `dateRecorded`) — a value is a value; a
|
||||
* consumer cannot tell the difference and shouldn't need to.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'goals:backfillHistory',
|
||||
description: 'Import historical goal values from a CSV into zp_goal_history.',
|
||||
)]
|
||||
class BackfillGoalHistoryCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Path to a CSV with header row: itemId,value,dateRecorded[,userId]')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Parse and validate the CSV without inserting.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
! defined('BASE_URL') && define('BASE_URL', '');
|
||||
! defined('CURRENT_URL') && define('CURRENT_URL', '');
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$path = $input->getOption('file');
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
if ($path === null || $path === '') {
|
||||
$io->error('Missing --file <path.csv>');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
if (! is_file($path) || ! is_readable($path)) {
|
||||
$io->error("File not found or unreadable: {$path}");
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$handle = fopen($path, 'r');
|
||||
if ($handle === false) {
|
||||
$io->error("Could not open {$path}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$header = fgetcsv($handle);
|
||||
if ($header === false) {
|
||||
fclose($handle);
|
||||
$io->error('CSV is empty.');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
$header = array_map(static fn ($h) => is_string($h) ? strtolower(trim($h)) : '', $header);
|
||||
|
||||
// Match by header name so column order doesn't matter — a spreadsheet
|
||||
// exported from anywhere can be fed in without hand-editing.
|
||||
$idxItem = array_search('itemid', $header, true);
|
||||
$idxValue = array_search('value', $header, true);
|
||||
$idxDate = array_search('daterecorded', $header, true);
|
||||
$idxUser = array_search('userid', $header, true);
|
||||
|
||||
if ($idxItem === false || $idxValue === false || $idxDate === false) {
|
||||
fclose($handle);
|
||||
$io->error('CSV header must include: itemId, value, dateRecorded (userId optional).');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$errors = [];
|
||||
$lineNo = 1;
|
||||
while (($record = fgetcsv($handle)) !== false) {
|
||||
$lineNo++;
|
||||
if ($record === [null] || $record === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemId = (int) ($record[$idxItem] ?? 0);
|
||||
$rawValue = $record[$idxValue] ?? '';
|
||||
$rawDate = trim((string) ($record[$idxDate] ?? ''));
|
||||
|
||||
if ($itemId <= 0) {
|
||||
$errors[] = "line {$lineNo}: itemId missing or invalid";
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($rawValue === '' || ! is_numeric($rawValue)) {
|
||||
$errors[] = "line {$lineNo}: value missing or not numeric";
|
||||
|
||||
continue;
|
||||
}
|
||||
// zp_goal_history rows are stored in UTC (the period reads in the Goalcanvas
|
||||
// repository normalize to UTC) — parse offset-less inputs AS UTC and normalize
|
||||
// offset-carrying inputs TO UTC, never the server timezone.
|
||||
try {
|
||||
$dateRecorded = (new \Carbon\CarbonImmutable($rawDate, 'UTC'))->setTimezone('UTC');
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "line {$lineNo}: unparseable dateRecorded '{$rawDate}'";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'itemId' => $itemId,
|
||||
'value' => (float) $rawValue,
|
||||
'userId' => ($idxUser !== false && isset($record[$idxUser]) && $record[$idxUser] !== '') ? (int) $record[$idxUser] : null,
|
||||
'dateRecorded' => $dateRecorded->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
if ($errors !== []) {
|
||||
$io->section('Validation errors');
|
||||
foreach ($errors as $e) {
|
||||
$io->writeln(' - '.$e);
|
||||
}
|
||||
$io->error(sprintf('%d row(s) invalid; nothing written.', count($errors)));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$io->warning('No data rows after header — nothing to import.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Reject itemIds that aren't actually goals BEFORE writing. The
|
||||
// history table has no FK to zp_canvas_items so nothing else would
|
||||
// catch a CSV pointing at a non-goal id (or an id that doesn't
|
||||
// exist). Silent inserts against phantom ids would just be dead
|
||||
// rows nobody ever reads.
|
||||
try {
|
||||
$repo = app()->make(GoalcanvasRepo::class);
|
||||
$csvItemIds = array_map(static fn ($r) => (int) $r['itemId'], $rows);
|
||||
$validItemIds = array_flip($repo->filterGoalItemIds($csvItemIds));
|
||||
} catch (\Throwable $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$rejected = [];
|
||||
$rows = array_values(array_filter($rows, static function ($row) use ($validItemIds, &$rejected): bool {
|
||||
if (! isset($validItemIds[(int) $row['itemId']])) {
|
||||
$rejected[(int) $row['itemId']] = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
if ($rejected !== []) {
|
||||
$io->warning(sprintf(
|
||||
'%d row(s) skipped — itemId not found or not a goal: %s',
|
||||
count($rejected),
|
||||
implode(', ', array_keys($rejected))
|
||||
));
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$io->error('No valid rows remain after filtering; nothing written.');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->success(sprintf('Dry run: %d row(s) would be inserted.', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$written = $repo->insertGoalHistoryRows($rows);
|
||||
} catch (\Throwable $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Backfill is NOT idempotent per-day the way the nightly job is —
|
||||
// re-running with the same CSV duplicates rows. Loud in the success
|
||||
// message so re-runs don't quietly double the data.
|
||||
$io->success(sprintf('Imported %d row(s) into zp_goal_history.', $written));
|
||||
$io->note('Re-running this command with the same CSV will duplicate rows. This command is one-shot; the nightly reports:goalValueSnapshot job handles ongoing captures.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
84
app/Command/BackupDbCommand.php
Normal file
84
app/Command/BackupDbCommand.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Class BackupDbCommand
|
||||
*
|
||||
* Command to back up the database.
|
||||
*
|
||||
* Usage:
|
||||
* php bin/console db:backup
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'db:backup',
|
||||
description: 'Backs up database',
|
||||
)]
|
||||
class BackupDbCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command
|
||||
*
|
||||
*
|
||||
* @return int 0 if everything went fine, or an exit code.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
|
||||
$config = app()->make(Environment::class);
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$date = new \DateTime;
|
||||
$backupFile = $config->dbDatabase.'_'.$date->format('Y-m-d').'.sql';
|
||||
$backupPath = APP_ROOT.'/'.$config->dbBackupPath.$backupFile;
|
||||
|
||||
if (! is_dir(APP_ROOT.'/'.$config->dbBackupPath)) {
|
||||
mkdir(APP_ROOT.'/'.$config->dbBackupPath);
|
||||
}
|
||||
|
||||
$output = [];
|
||||
$cmd = sprintf(
|
||||
'mysqldump --column-statistics=0 --user=\'%s\' --password=\'%s\' --host=%s %s --port=%s --result-file=%s 2>&1',
|
||||
$config->dbUser,
|
||||
$config->dbPassword,
|
||||
$config->dbHost == 'localhost' ? '127.0.0.1' : $config->dbHost,
|
||||
$config->dbDatabase,
|
||||
$config->dbPort,
|
||||
$backupPath
|
||||
);
|
||||
exec($cmd, $output, $worked);
|
||||
|
||||
switch ($worked) {
|
||||
case 0:
|
||||
chmod(APP_ROOT.'/'.$config->userFilePath, 0755);
|
||||
$io->success('Success, database was backedup successfully');
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
case 2:
|
||||
case 1:
|
||||
$io->error('There was an issue backing up the database');
|
||||
$io->listing($output);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
193
app/Command/CheckEventListeners.php
Normal file
193
app/Command/CheckEventListeners.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use DigitalJoeCo\Leantime\Documentor\Documentor;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'event:check-listeners',
|
||||
description: 'Validate event listener paths against available events',
|
||||
)]
|
||||
class CheckEventListeners extends Command
|
||||
{
|
||||
protected $signature = 'event:check-listeners {--debug : Show detailed debug information} {--clear-cache : Clear the event cache}';
|
||||
|
||||
protected $description = 'Check if all registered event listeners match existing events';
|
||||
|
||||
protected $events = [];
|
||||
|
||||
protected $listeners = [];
|
||||
|
||||
protected $cacheFile = 'storage/event_cache.json';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
ini_set('memory_limit', '1024M'); // Increase memory limit to 1GB
|
||||
|
||||
$this->info('Scanning for event contexts and dispatched events...');
|
||||
if ($this->option('clear-cache')) {
|
||||
$this->clearCache();
|
||||
}
|
||||
|
||||
if ($this->loadFromCache()) {
|
||||
$this->info('Loaded events from cache.');
|
||||
} else {
|
||||
$this->scanForEventContexts();
|
||||
$this->storeToCache();
|
||||
}
|
||||
|
||||
$this->scanForListeners();
|
||||
$this->validateListeners();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function scanForEventContexts(): void
|
||||
{
|
||||
$files = File::allFiles(app_path());
|
||||
$finder = new Finder;
|
||||
$finder->files()
|
||||
->in('app')
|
||||
->notPath('vendor')
|
||||
->notPath('Plugins/*/vendor')
|
||||
->name('*.php');
|
||||
|
||||
$documentor = new Documentor($this->output);
|
||||
$documentor->relative = 'app';
|
||||
|
||||
$progress_bar = new ProgressBar($this->output, \iterator_count($finder));
|
||||
ProgressBar::setFormatDefinition('custom', ' %current%/%max% -- %message% (%filename%)');
|
||||
|
||||
$progress_bar->setFormat('custom');
|
||||
|
||||
$progress_bar->setMessage('Finding Events');
|
||||
$progress_bar->start();
|
||||
|
||||
foreach ($finder as $file) {
|
||||
|
||||
$progress_bar->setMessage('Processing file…');
|
||||
$progress_bar->setMessage($file->getPathname(), 'filename');
|
||||
|
||||
$this->parseFileForEventContexts($file, $documentor);
|
||||
|
||||
$progress_bar->advance();
|
||||
|
||||
}
|
||||
|
||||
$progress_bar->setMessage('Completed parsing files');
|
||||
$progress_bar->finish();
|
||||
}
|
||||
|
||||
private function parseFileForEventContexts($file, Documentor $documentor): void
|
||||
{
|
||||
try {
|
||||
if ($this->option('debug')) {
|
||||
// $this->info("Parsing file: {$file->getPathname()}");
|
||||
}
|
||||
|
||||
$documentor->parse($file);
|
||||
|
||||
foreach ($documentor->get_hooks() as $hook) {
|
||||
$context = 'Leantime.'.$hook->get_hook();
|
||||
if ($this->option('debug')) {
|
||||
$this->info("Found event: {$context}");
|
||||
}
|
||||
|
||||
if (! in_array($context, $this->events)) {
|
||||
$this->events[] = $context;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Failed to parse file: {$file->getPathname()} - ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function scanForListeners(): void
|
||||
{
|
||||
$registries = EventDispatcher::get_registries();
|
||||
$this->listeners = array_merge($registries['events'], $registries['filters']);
|
||||
}
|
||||
|
||||
private function loadFromCache(): bool
|
||||
{
|
||||
if (file_exists($this->cacheFile)) {
|
||||
$this->events = json_decode(file_get_contents($this->cacheFile), true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function storeToCache(): void
|
||||
{
|
||||
file_put_contents($this->cacheFile, json_encode($this->events));
|
||||
}
|
||||
|
||||
private function clearCache(): void
|
||||
{
|
||||
if (file_exists($this->cacheFile)) {
|
||||
unlink($this->cacheFile);
|
||||
$this->info('Cache cleared.');
|
||||
}
|
||||
}
|
||||
|
||||
private function validateListeners(): void
|
||||
{
|
||||
$normalizedEvents = array_map('strtolower', $this->events);
|
||||
$unmatchedListeners = [];
|
||||
|
||||
foreach ($this->listeners as $listener) {
|
||||
$listener = strtolower($listener);
|
||||
if (! $this->matchesAnyEvent($listener, $normalizedEvents)) {
|
||||
$unmatchedListeners[] = $listener;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($unmatchedListeners)) {
|
||||
$this->warn('Unmatched listeners found:');
|
||||
foreach ($unmatchedListeners as $listener) {
|
||||
$this->line("- $listener");
|
||||
}
|
||||
} else {
|
||||
$this->info('All listeners have corresponding events.');
|
||||
}
|
||||
}
|
||||
|
||||
private function matchesAnyEvent(string $listener, array $events): bool
|
||||
{
|
||||
|
||||
preg_match_all('/\{RGX:(.*?):RGX\}/', $listener, $regexMatches);
|
||||
|
||||
$key = strtr($listener, [
|
||||
...collect($regexMatches[0])->mapWithKeys(fn ($match, $i) => [$match => "REGEX_MATCH_$i"])->toArray(),
|
||||
'*' => 'RANDOM_STRING',
|
||||
'?' => 'RANDOM_CHARACTER',
|
||||
]);
|
||||
|
||||
// escape the non regex characters
|
||||
$pattern = preg_quote($key, '/');
|
||||
|
||||
$pattern = strtr($pattern, [
|
||||
'RANDOM_STRING' => '.*?', // 0 or more (lazy) - asterisk (*)
|
||||
'RANDOM_CHARACTER' => '.', // 1 character - question mark (?)
|
||||
...collect($regexMatches[1])->mapWithKeys(fn ($match, $i) => ["REGEX_MATCH_$i" => $match])->toArray(),
|
||||
]);
|
||||
|
||||
foreach ($events as $event) {
|
||||
if (preg_match("/^$pattern$/", $event)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
148
app/Command/CheckTranslations.php
Normal file
148
app/Command/CheckTranslations.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'translations:check-unused',
|
||||
description: 'Check for unused translation strings in the codebase',
|
||||
)]
|
||||
class CheckTranslations extends Command
|
||||
{
|
||||
protected $signature = 'translations:check-unused
|
||||
{--debug : Show detailed debug information}
|
||||
{--export= : Export results to a file}
|
||||
{--exclude=vendor,node_modules,.git,storage,cache : Comma separated list of directories to exclude}';
|
||||
|
||||
protected $description = 'Scan codebase for unused translation strings';
|
||||
|
||||
protected $translations = [];
|
||||
|
||||
protected $usedTranslations = [];
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Scanning for translation strings...');
|
||||
|
||||
// Parse the language file
|
||||
$this->parseLanguageFile();
|
||||
|
||||
// Scan files for usage
|
||||
$this->scanFiles();
|
||||
|
||||
// Generate report
|
||||
$this->generateReport();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function parseLanguageFile(): void
|
||||
{
|
||||
$langFile = app_path('Language/en-US.ini');
|
||||
if (! file_exists($langFile)) {
|
||||
$this->error('Language file not found: '.$langFile);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info('Parsing language file...');
|
||||
$this->translations = parse_ini_file($langFile);
|
||||
|
||||
if ($this->option('debug')) {
|
||||
$this->info(sprintf('Found %d translation strings', count($this->translations)));
|
||||
}
|
||||
}
|
||||
|
||||
private function scanFiles(): void
|
||||
{
|
||||
$excludeDirs = explode(',', $this->option('exclude'));
|
||||
|
||||
if ($this->option('debug')) {
|
||||
$this->info('Excluding directories: '.implode(', ', $excludeDirs));
|
||||
}
|
||||
|
||||
$finder = new Finder;
|
||||
$finder->files()
|
||||
->in(app_path())
|
||||
->name('*.php')
|
||||
->name('*.tpl.php')
|
||||
->name('*.html')
|
||||
->name('*.blade.php')
|
||||
->name('*.js')
|
||||
->notPath($excludeDirs);
|
||||
|
||||
$progress = new ProgressBar($this->output, iterator_count($finder));
|
||||
$progress->setFormat('debug');
|
||||
$progress->start();
|
||||
|
||||
foreach ($finder as $file) {
|
||||
$this->scanFileForTranslations($file);
|
||||
$progress->advance();
|
||||
}
|
||||
|
||||
$progress->finish();
|
||||
$this->line('');
|
||||
}
|
||||
|
||||
private function scanFileForTranslations($file): void
|
||||
{
|
||||
$content = file_get_contents($file->getRealPath());
|
||||
$filePath = $file->getRelativePathname();
|
||||
|
||||
foreach ($this->translations as $key => $value) {
|
||||
// Search for various usage patterns
|
||||
$patterns = [
|
||||
preg_quote($key, '/'), // Direct key usage
|
||||
preg_quote("'$key'", '/'), // Single quoted
|
||||
preg_quote("\"$key\"", '/'), // Double quoted
|
||||
preg_quote('__("'.$key.'")', '/'), // PHP translation function
|
||||
preg_quote("__('$key')", '/'), // PHP translation function
|
||||
preg_quote('$tpl->__("'.$key.'")', '/'), // Template translation
|
||||
preg_quote('$tpl->__(\''.$key.'\')', '/'), // Template translation
|
||||
];
|
||||
|
||||
if ($this->option('debug')) {
|
||||
$this->line("Scanning file: {$filePath}");
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match('/'.$pattern.'/', $content)) {
|
||||
if ($this->option('debug')) {
|
||||
$this->line(" - Found usage of key: {$key}");
|
||||
}
|
||||
$this->usedTranslations[$key] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function generateReport(): void
|
||||
{
|
||||
$unusedTranslations = array_diff_key($this->translations, $this->usedTranslations);
|
||||
|
||||
if (empty($unusedTranslations)) {
|
||||
$this->info('No unused translations found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warn(sprintf('Found %d unused translations:', count($unusedTranslations)));
|
||||
|
||||
foreach ($unusedTranslations as $key => $value) {
|
||||
$this->line("- $key");
|
||||
}
|
||||
|
||||
if ($exportFile = $this->option('export')) {
|
||||
file_put_contents(
|
||||
$exportFile,
|
||||
json_encode($unusedTranslations, JSON_PRETTY_PRINT)
|
||||
);
|
||||
$this->info("Results exported to: $exportFile");
|
||||
}
|
||||
}
|
||||
}
|
||||
139
app/Command/CleanupOrphanedFilesCommand.php
Normal file
139
app/Command/CleanupOrphanedFilesCommand.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CleanupOrphanedFilesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'files:cleanup {--dry-run : Run without actually deleting files}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Clean up orphaned files that are not referenced in the database';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle(FilesystemManager $filesystemManager)
|
||||
{
|
||||
$this->info('Starting orphaned files cleanup...');
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->warn('Running in dry-run mode. No files will be deleted.');
|
||||
}
|
||||
|
||||
// Get all files from database
|
||||
$dbFiles = DB::table('zp_file')->select('encName', 'extension')->get();
|
||||
$dbFileNames = [];
|
||||
|
||||
foreach ($dbFiles as $file) {
|
||||
$dbFileNames[] = $file->encName.'.'.$file->extension;
|
||||
}
|
||||
|
||||
$this->info('Found '.count($dbFileNames).' files in database.');
|
||||
|
||||
// Process local storage
|
||||
$this->processStorage($filesystemManager, 'local', $dbFileNames, $dryRun);
|
||||
|
||||
// Process public storage
|
||||
$this->processStorage($filesystemManager, 'public', $dbFileNames, $dryRun);
|
||||
|
||||
// Process S3 storage if configured
|
||||
if (config('filesystems.default') === 's3') {
|
||||
$this->processStorage($filesystemManager, 's3', $dbFileNames, $dryRun);
|
||||
}
|
||||
|
||||
$this->info('Orphaned files cleanup completed.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a specific storage disk to find and delete orphaned files
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function processStorage(FilesystemManager $filesystemManager, string $disk, array $dbFileNames, bool $dryRun)
|
||||
{
|
||||
$this->info("Processing {$disk} storage...");
|
||||
|
||||
try {
|
||||
$storage = $filesystemManager->disk($disk);
|
||||
$files = $storage->files();
|
||||
|
||||
$orphanedFiles = array_filter($files, function ($file) use ($dbFileNames) {
|
||||
$fileName = basename($file);
|
||||
|
||||
return ! in_array($fileName, $dbFileNames);
|
||||
});
|
||||
|
||||
$this->info('Found '.count($orphanedFiles)." orphaned files in {$disk} storage.");
|
||||
|
||||
$deletedCount = 0;
|
||||
foreach ($orphanedFiles as $file) {
|
||||
// Skip system files and directories
|
||||
if (in_array(basename($file), ['.gitignore', '.htaccess', 'index.html'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line("Processing: {$file}");
|
||||
|
||||
// Check file age (only delete files older than 24 hours)
|
||||
try {
|
||||
$lastModified = $storage->lastModified($file);
|
||||
$fileAge = time() - $lastModified;
|
||||
|
||||
if ($fileAge < 86400) { // 24 hours
|
||||
$this->warn("Skipping {$file} - file is less than 24 hours old");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
if ($storage->delete($file)) {
|
||||
$this->info("Deleted: {$file}");
|
||||
$deletedCount++;
|
||||
} else {
|
||||
$this->error("Failed to delete: {$file}");
|
||||
}
|
||||
} else {
|
||||
$this->info("Would delete: {$file} (dry run)");
|
||||
$deletedCount++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Error processing {$file}: ".$e->getMessage());
|
||||
Log::error('Error in orphaned files cleanup: '.$e->getMessage(), [
|
||||
'file' => $file,
|
||||
'disk' => $disk,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$actionText = $dryRun ? 'Would have deleted' : 'Deleted';
|
||||
$this->info("{$actionText} {$deletedCount} orphaned files from {$disk} storage.");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Error accessing {$disk} storage: ".$e->getMessage());
|
||||
Log::error('Error accessing storage in orphaned files cleanup: '.$e->getMessage(), [
|
||||
'disk' => $disk,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/Command/ClearAll.php
Normal file
71
app/Command/ClearAll.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Class BackupDbCommand
|
||||
*
|
||||
* Command to back up the database.
|
||||
*
|
||||
* Usage:
|
||||
* php bin/console db:backup
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'cache:clearAll',
|
||||
description: 'Clear all caches',
|
||||
)]
|
||||
class ClearAll extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command
|
||||
*
|
||||
*
|
||||
* @return int 0 if everything went fine, or an exit code.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
|
||||
$config = app()->make(Environment::class);
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->section('Clearing all caches');
|
||||
|
||||
$io->text('Clear Views');
|
||||
$this->call('view:clear');
|
||||
|
||||
$io->text('Clear Installation Cache');
|
||||
Cache::store('installation')->forget('languages.lang_en-US');
|
||||
$this->components->info('cleared language file cache');
|
||||
|
||||
// $this->call("cache:clear", ["installation"]);
|
||||
|
||||
$io->text('Clear Bootstrap Cache');
|
||||
$commandOut = exec('rm -rf ./storage/framework/composerPaths.php');
|
||||
$this->components->info('composerPaths removed');
|
||||
$commandOut = exec('rm -rf ./storage/framework/viewPaths.php');
|
||||
$this->components->info('viewpaths removed');
|
||||
$commandOut = exec('rm -rf ./bootstrap/cache/*.php');
|
||||
$this->components->info('bootstrap cache cleared');
|
||||
|
||||
$io->success('All Caches cleared');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
70
app/Command/ClearLanguage.php
Normal file
70
app/Command/ClearLanguage.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Language;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Class BackupDbCommand
|
||||
*
|
||||
* Command to back up the database.
|
||||
*
|
||||
* Usage:
|
||||
* php bin/console db:backup
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'language:clear',
|
||||
description: 'Clear all cached language files',
|
||||
)]
|
||||
class ClearLanguage extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command
|
||||
*
|
||||
*
|
||||
* @return int 0 if everything went fine, or an exit code.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
|
||||
$config = app()->make(Environment::class);
|
||||
$language = app()->make(Language::class);
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->section('Clearing Language Cache');
|
||||
|
||||
$langList = $language->getLanguageList();
|
||||
|
||||
if ($langList) {
|
||||
foreach ($langList as $key => $lang) {
|
||||
$result = Cache::store('installation')->forget('languages.lang_'.$key);
|
||||
if ($result) {
|
||||
$this->components->info('Cleared: '.$key);
|
||||
} else {
|
||||
$this->components->warn('Failed to clear: '.$key);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$this->components->info('cleared language file cache');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
86
app/Command/CreateBearerTokenCommand.php
Normal file
86
app/Command/CreateBearerTokenCommand.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'auth:create-bearer-token',
|
||||
description: 'Mint a Sanctum-style Bearer token for an existing user. Plugin-independent — does not require AdvancedAuth. CLI-only.',
|
||||
)]
|
||||
class CreateBearerTokenCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this->addOption('email', null, InputOption::VALUE_REQUIRED, 'Email of an existing user to mint a token for')
|
||||
->addOption('name', null, InputOption::VALUE_OPTIONAL, 'Token name (logged on the row, useful for traceability)', 'cli-issued')
|
||||
->addOption('quiet-output', null, InputOption::VALUE_NONE, 'Print only the token (no decoration). Useful for shell capture.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
// Hard CLI gate. The Laravel console kernel already only runs
|
||||
// commands from CLI in practice, but a future HTTP-served
|
||||
// Artisan::call() (or a misconfigured kernel) must not be able
|
||||
// to mint a bearer through here. Bail before any work happens.
|
||||
if (! app()->runningInConsole()) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
! defined('BASE_URL') && define('BASE_URL', '');
|
||||
! defined('CURRENT_URL') && define('CURRENT_URL', '');
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$email = $input->getOption('email');
|
||||
if ($email === null || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$io->error('A valid --email is required');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
try {
|
||||
$usersRepo = app()->make(Users::class);
|
||||
$user = $usersRepo->getUserByEmail($email);
|
||||
|
||||
if (! $user || empty($user['id'])) {
|
||||
$io->error("No user found with email: {$email}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$tokenRepo = app()->make(AccessTokenRepository::class);
|
||||
$result = $tokenRepo->createToken((int) $user['id'], (string) $input->getOption('name'));
|
||||
} catch (\Throwable $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($input->getOption('quiet-output')) {
|
||||
$output->write($result['token']);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
"Bearer token minted for user #%d (%s)\nToken id: %d\nToken: %s\n\nUse with: Authorization: Bearer %s",
|
||||
$user['id'],
|
||||
$email,
|
||||
$result['id'],
|
||||
$result['token'],
|
||||
$result['token'],
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
51
app/Command/DisablePluginCommand.php
Normal file
51
app/Command/DisablePluginCommand.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
|
||||
/**
|
||||
* Class DisablePluginCommand
|
||||
*
|
||||
* This class represents a command that disables plugins.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'plugin:disable',
|
||||
description: 'Disable a plugin',
|
||||
)]
|
||||
class DisablePluginCommand extends AbstractPluginCommand
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('plugin', InputArgument::REQUIRED, 'The plugin name');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function executeCommand(): int
|
||||
{
|
||||
$name = $this->input->getArgument('plugin');
|
||||
$plugin = $this->getPlugin($name);
|
||||
|
||||
if (! isset($plugin->id)) {
|
||||
throw new RuntimeException(sprintf('Plugin %s is not installed', $plugin->name));
|
||||
}
|
||||
|
||||
if (! $plugin->enabled) {
|
||||
throw new RuntimeException(sprintf('Plugin %s is not enabled', $plugin->name));
|
||||
}
|
||||
|
||||
if (! $this->confirm(sprintf('Disable plugin %s', $plugin->name))) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
return $this->plugins->disablePlugin($plugin->id) ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
51
app/Command/EnablePluginCommand.php
Normal file
51
app/Command/EnablePluginCommand.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
|
||||
/**
|
||||
* Class EnablePluginCommand
|
||||
*
|
||||
* This class represents a command that enables plugins.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'plugin:enable',
|
||||
description: 'Enable a plugin',
|
||||
)]
|
||||
class EnablePluginCommand extends AbstractPluginCommand
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('plugin', InputArgument::REQUIRED, 'The plugin name');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function executeCommand(): int
|
||||
{
|
||||
$name = $this->input->getArgument('plugin');
|
||||
$plugin = $this->getPlugin($name);
|
||||
|
||||
if (! isset($plugin->id)) {
|
||||
throw new RuntimeException(sprintf('Plugin %s is not installed', $plugin->name));
|
||||
}
|
||||
|
||||
if ($plugin->enabled) {
|
||||
throw new RuntimeException(sprintf('Plugin %s is already enabled', $plugin->name));
|
||||
}
|
||||
|
||||
if (! $this->confirm(sprintf('Enable plugin %s', $plugin->name))) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
return $this->plugins->enablePlugin($plugin->id) ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
47
app/Command/InstallPluginCommand.php
Normal file
47
app/Command/InstallPluginCommand.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
|
||||
/**
|
||||
* Class InstallPluginCommand
|
||||
*
|
||||
* This class represents a command that installs plugins.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'plugin:install',
|
||||
description: 'Install a plugin',
|
||||
)]
|
||||
class InstallPluginCommand extends AbstractPluginCommand
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('plugin', InputArgument::REQUIRED, 'The plugin name');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function executeCommand(): int
|
||||
{
|
||||
$name = $this->input->getArgument('plugin');
|
||||
$plugin = $this->getPlugin($name);
|
||||
|
||||
if (! isset($plugin->foldername)) {
|
||||
throw new RuntimeException(sprintf('Plugin %s cannot be installed', $plugin->name));
|
||||
}
|
||||
|
||||
if (! $this->confirm(sprintf('Install plugin %s', $plugin->name))) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
return $this->plugins->installPlugin($plugin->foldername) ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
93
app/Command/ListPluginCommand.php
Normal file
93
app/Command/ListPluginCommand.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Leantime\Domain\Plugins\Models\InstalledPlugin;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
* Class ListPluginCommand
|
||||
*
|
||||
* This class represents a command that lists all plugins.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'plugin:list',
|
||||
description: 'List all plugins',
|
||||
)]
|
||||
final class ListPluginCommand extends AbstractPluginCommand
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('order-by', null, InputOption::VALUE_REQUIRED, 'Plugin order', 'name');
|
||||
$this->addOption('installed', null, InputOption::VALUE_REQUIRED, 'Filter plugins on installed status');
|
||||
$this->addOption('enabled', null, InputOption::VALUE_REQUIRED, 'Filter plugins on enabled status');
|
||||
$this->setHelp(<<<'EOL'
|
||||
Examples
|
||||
|
||||
Show all plugins:
|
||||
|
||||
bin/leantime plugin:list
|
||||
|
||||
Show only installed plugins:
|
||||
|
||||
bin/leantime plugin:list --installed=true
|
||||
|
||||
Show only non-installed plugins:
|
||||
|
||||
bin/leantime plugin:list --installed=false
|
||||
|
||||
Show only enabled plugins:
|
||||
|
||||
bin/leantime plugin:list --enabled=true
|
||||
|
||||
Show plugins that are both installed and enabled:
|
||||
|
||||
bin/leantime plugin:list --installed=true --enabled=true
|
||||
|
||||
EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function executeCommand(): int
|
||||
{
|
||||
$plugins = $this->getAllPlugins();
|
||||
|
||||
// Filter by “installed" if requested.
|
||||
$installed = $this->input->getOption('installed');
|
||||
if ($installed !== null) {
|
||||
$installed = filter_var($installed, FILTER_VALIDATE_BOOLEAN);
|
||||
$plugins = array_filter($plugins, fn (InstalledPlugin $p) => ! ($installed xor isset($p->id)));
|
||||
}
|
||||
|
||||
// Filter by “enabled" if requested.
|
||||
$enabled = $this->input->getOption('enabled');
|
||||
if ($enabled !== null) {
|
||||
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
|
||||
$plugins = array_filter($plugins, fn (InstalledPlugin $p) => ! ($enabled xor $p->enabled));
|
||||
}
|
||||
|
||||
$orderBy = $this->input->getOption('order-by');
|
||||
usort($plugins, static fn (InstalledPlugin $p0, InstalledPlugin $p1) => ($p0->{$orderBy} ?? null) <=> ($p1->{$orderBy} ?? null));
|
||||
|
||||
foreach ($plugins as $plugin) {
|
||||
$this->io->definitionList(
|
||||
// Pad name to line up with wrapped description.
|
||||
['Name' => u($plugin->name)->padEnd(60)],
|
||||
['Description' => u($plugin->description)->wordwrap(60)],
|
||||
['Installed' => isset($plugin->id) ? 'yes' : 'no'],
|
||||
['Enabled' => $plugin->enabled ? 'yes' : 'no'],
|
||||
);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
133
app/Command/MigrateCommand.php
Normal file
133
app/Command/MigrateCommand.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Domain\Help\Services\Helper;
|
||||
use Leantime\Domain\Install\Repositories\Install;
|
||||
use Leantime\Domain\Users\Repositories\Users;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Represents the MigrateCommand class.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'db:migrate',
|
||||
description: 'Runs and pending Leantime Database Migrations',
|
||||
)]
|
||||
class MigrateCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this->setAliases(['db:install', 'db:update']);
|
||||
|
||||
$this->addOption('email', null, InputOption::VALUE_OPTIONAL, "User's Email", null)
|
||||
->addOption('password', null, InputOption::VALUE_OPTIONAL, "User's Password", null)
|
||||
->addOption('company-name', null, InputOption::VALUE_OPTIONAL, 'Company Name', null)
|
||||
->addOption('first-name', null, InputOption::VALUE_OPTIONAL, "User's First name", null)
|
||||
->addOption('last-name', null, InputOption::VALUE_OPTIONAL, "User's Last Name", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command
|
||||
*
|
||||
*
|
||||
* @return int 0 if everything went fine, or an exit code.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
! defined('BASE_URL') && define('BASE_URL', '');
|
||||
! defined('CURRENT_URL') && define('CURRENT_URL', '');
|
||||
|
||||
$install = app()->make(Install::class);
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$silent = $input->getOption('silent') === 'true';
|
||||
try {
|
||||
if (! $install->checkIfInstalled()) {
|
||||
if ($silent) {
|
||||
$adminEmail = 'admin@leantime.io';
|
||||
$setupConfig = [
|
||||
'email' => 'admin@leantime.io',
|
||||
'password' => '',
|
||||
'firstname' => '',
|
||||
'lastname' => '',
|
||||
'company' => 'Leantime',
|
||||
];
|
||||
} else {
|
||||
$email = $input->getOption('email');
|
||||
$password = $input->getOption('password');
|
||||
$companyName = $input->getOption('company-name');
|
||||
$firstName = $input->getOption('first-name');
|
||||
$lastName = $input->getOption('last-name');
|
||||
|
||||
$adminEmail = $email ?? $io->ask('Admin Email');
|
||||
$adminPassword = $password ?? $io->askHidden('Admin Password');
|
||||
$adminFirstName = $firstName ?? $io->ask('Admin First Name');
|
||||
$adminLastName = $lastName ?? $io->ask('Admin Last Name');
|
||||
$companyName = $companyName ?? $io->ask('Company Name');
|
||||
|
||||
$setupConfig = [
|
||||
'email' => $adminEmail,
|
||||
'password' => $adminPassword,
|
||||
'firstname' => $adminFirstName,
|
||||
'lastname' => $adminLastName,
|
||||
'company' => $companyName,
|
||||
];
|
||||
}
|
||||
|
||||
$io->text('Installing DB For First Time');
|
||||
$installStatus = $install->setupDB($setupConfig);
|
||||
|
||||
if ($installStatus !== true) {
|
||||
// setupDB() returns bool; on failure it logs the cause. Give the CLI user a
|
||||
// concrete pointer instead of the empty string (string) false would produce.
|
||||
$io->error('Database installation failed. Check the application logs for details.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Latest install via UI sends user to account set up flow.
|
||||
// Turning this off for console installs
|
||||
$usersRepo = app()->make(Users::class);
|
||||
$getAdminUser = $usersRepo->getUserByEmail($adminEmail, '');
|
||||
if ($getAdminUser !== false && is_array($getAdminUser)) {
|
||||
$userId = $getAdminUser['id'];
|
||||
$usersRepo->patchUser($userId, ['password' => $setupConfig['password'], 'status' => 'a']);
|
||||
|
||||
$helperService = app()->make(Helper::class);
|
||||
$helperService->createDefaultProject($userId, 'owner');
|
||||
}
|
||||
|
||||
if ($silent) {
|
||||
$usersRepo = app()->make(Users::class);
|
||||
$userId = array_values($usersRepo->getUserByEmail($adminEmail))[0];
|
||||
$usersRepo->deleteUser($userId);
|
||||
}
|
||||
|
||||
$io->text('Successfully Installed DB');
|
||||
}
|
||||
$success = $install->updateDB();
|
||||
if ($success !== true) {
|
||||
throw new Exception('Migration Failed; See below'.PHP_EOL.implode(PHP_EOL, $success));
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
$io->error($ex);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success('Database Successfully Migrated');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
47
app/Command/RemovePluginCommand.php
Normal file
47
app/Command/RemovePluginCommand.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
|
||||
/**
|
||||
* Class RemovePluginCommand
|
||||
*
|
||||
* This class represents a command that removes plugins.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'plugin:remove',
|
||||
description: 'Remove a plugin',
|
||||
)]
|
||||
class RemovePluginCommand extends AbstractPluginCommand
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('plugin', InputArgument::REQUIRED, 'The plugin name');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function executeCommand(): int
|
||||
{
|
||||
$name = $this->input->getArgument('plugin');
|
||||
$plugin = $this->getPlugin($name);
|
||||
|
||||
if (! isset($plugin->id)) {
|
||||
throw new RuntimeException(sprintf('Plugin %s is not installed', $plugin->name));
|
||||
}
|
||||
|
||||
if (! $this->confirm(sprintf('Remove plugin %s', $plugin->name))) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
return $this->plugins->removePlugin($plugin->id) ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
80
app/Command/SaveSettingCommand.php
Normal file
80
app/Command/SaveSettingCommand.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Class SaveSettingCommand
|
||||
*
|
||||
* Command for saving a setting, will create it if it doesn't exist.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'setting:save',
|
||||
description: 'Saves a setting, will create it if it does not exist',
|
||||
)]
|
||||
class SaveSettingCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
$this->addOption('key', null, InputOption::VALUE_REQUIRED, 'Setting Key')
|
||||
->addOption('value', null, InputOption::VALUE_REQUIRED, 'Setting Value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command
|
||||
*
|
||||
*
|
||||
* @return int 0 if everything went fine, or an exit code.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
! defined('BASE_URL') && define('BASE_URL', '');
|
||||
! defined('CURRENT_URL') && define('CURRENT_URL', '');
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$key = $input->getOption('key');
|
||||
$value = $input->getOption('value');
|
||||
|
||||
if ($key == '') {
|
||||
$io->error('key parameter needs to be set');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
if ($value == '') {
|
||||
$io->error('value parameter needs to be set');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
try {
|
||||
$setting = app()->make(Setting::class);
|
||||
$result = $setting->saveSetting($key, $value);
|
||||
|
||||
if (! $result) {
|
||||
$io->error('Failed to save setting');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success('Saved Successfully');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
75
app/Command/SyncPermissionsCommand.php
Normal file
75
app/Command/SyncPermissionsCommand.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Leantime\Core\Auth\Permissions\PermissionRegistry;
|
||||
use Leantime\Core\Auth\Permissions\PermissionRepository;
|
||||
use Leantime\Core\Auth\Permissions\PermissionSeeder;
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Discovers every domain/plugin permission declaration and syncs the `domain.action`
|
||||
* vocabulary into the database.
|
||||
*
|
||||
* Usage:
|
||||
* php bin/leantime permissions:sync # upsert the vocabulary
|
||||
* php bin/leantime permissions:sync --seed # also (re)grant built-in role defaults
|
||||
* php bin/leantime permissions:sync --prune # also remove permissions no longer declared
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'permissions:sync',
|
||||
description: 'Sync the discovered permission vocabulary into the database',
|
||||
)]
|
||||
class SyncPermissionsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PermissionSeeder $seeder,
|
||||
private readonly PermissionRepository $repo,
|
||||
private readonly PermissionService $permissions,
|
||||
private readonly PermissionRegistry $registry,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this->addOption('seed', null, InputOption::VALUE_NONE, 'Also (re)grant the built-in roles their default permissions');
|
||||
$this->addOption('prune', null, InputOption::VALUE_NONE, 'Remove permissions that are no longer declared in code');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Re-glob providers from disk first — the discovered-provider list is cached
|
||||
// cross-request outside debug mode, so a stale cache (e.g. predating a newly-shipped
|
||||
// domain's permission class) would otherwise hide its permissions from the sync. This
|
||||
// also makes the command a reliable recovery step after such a cache goes stale.
|
||||
$this->registry->flush();
|
||||
|
||||
$keys = $this->seeder->syncDiscoveredPermissions();
|
||||
$io->success(count($keys).' permissions synced.');
|
||||
|
||||
if ($input->getOption('prune')) {
|
||||
$pruned = $this->repo->pruneOrphanPermissions($keys);
|
||||
$io->info($pruned.' orphaned permission(s) pruned.');
|
||||
}
|
||||
|
||||
if ($input->getOption('seed')) {
|
||||
$this->seeder->seedBuiltInRoles();
|
||||
$io->info('Built-in role defaults seeded.');
|
||||
}
|
||||
|
||||
$this->permissions->flushCache();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
70
app/Command/TestEmailCommand.php
Normal file
70
app/Command/TestEmailCommand.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Mailer;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Class TestEmailCommand
|
||||
*
|
||||
* This class represents a command that sends an email to test the system configuration.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'email:testemail',
|
||||
description: 'Sends an email to test system configuration',
|
||||
)]
|
||||
class TestEmailCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this->addOption('address', null, InputOption::VALUE_REQUIRED, 'Recipient email address');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command
|
||||
*
|
||||
* @return int 0 if everything went fine, or an exit code.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
// Depending on the entry point, the constants may not be defined
|
||||
! defined('BASE_URL') && define('BASE_URL', '');
|
||||
! defined('CURRENT_URL') && define('CURRENT_URL', '');
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$address = $input->getOption('address');
|
||||
if ($address == '') {
|
||||
$io->error('address parameter needs to be set');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$config = app()->make(Environment::class);
|
||||
|
||||
// Force debug output from the mailer subsystem
|
||||
$config->debug = 1;
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->writeln('Sending a test email using current configuration');
|
||||
|
||||
$mailer = app()->make(Mailer::class);
|
||||
$mailer->setSubject('Leantime email test');
|
||||
$mailer->setHtml('This is a test of the leantime mailer configuration. If you have received this email, then the mail configuration is correct.', true);
|
||||
$mailer->sendMail([$input->getOption('address')], 'Command-line test');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
192
app/Command/UpdateLeantime.php
Normal file
192
app/Command/UpdateLeantime.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Domain\Plugins\Services\Plugins;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Class UpdateLeantime
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'system:update',
|
||||
description: 'Updates Leantime to the latest version from Github',
|
||||
)]
|
||||
class UpdateLeantime extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
$this->addOption(name: 'skipDbBackup', mode: InputOption::VALUE_NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the update process.
|
||||
*
|
||||
* @param InputInterface $input The input interface object.
|
||||
* @param OutputInterface $output The output interface object.
|
||||
* @return int 0 if everything went fine, or an exit code.
|
||||
*
|
||||
* @throws BindingResolutionException|\Throwable
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$appSettings = app()->make(AppSettings::class);
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$currentVersion = $appSettings->appVersion;
|
||||
$io->text('Starting the updater');
|
||||
|
||||
// Check Versions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
|
||||
$io->section('Version Check');
|
||||
$io->text('Your current version is: v'.$currentVersion);
|
||||
$url = 'https://github.com/leantime/leantime/releases/latest';
|
||||
$io->text('Checking latest version on Github...');
|
||||
|
||||
// Create stream context to follow redirects
|
||||
$context = stream_context_create(
|
||||
[
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => 'Accept: application/json',
|
||||
'follow_location' => true,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Use file_get_contents() with HTTP context to fetch url
|
||||
$result = file_get_contents($url, false, $context);
|
||||
$jsonResponse = json_decode($result, true);
|
||||
$latestVersion = $jsonResponse['tag_name'] ?? null;
|
||||
|
||||
$io->text('The latest Leantime version is: '.$latestVersion);
|
||||
|
||||
// Build download URL
|
||||
if (version_compare($currentVersion, ltrim($latestVersion, 'v'), '>=')) {
|
||||
$io->success('You are on the most up to date version');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Backup DB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
|
||||
$io->section('Database Backup');
|
||||
$skipBackup = $input->getOption('skipDbBackup');
|
||||
|
||||
if ($skipBackup === false) {
|
||||
$backUp = new ArrayInput([
|
||||
// The command name is passed as first argument
|
||||
'command' => 'db:backup',
|
||||
]);
|
||||
|
||||
$this->getApplication()->doRun($backUp, $output);
|
||||
}
|
||||
|
||||
// Download and extract + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
|
||||
$io->section('Download & Extract');
|
||||
|
||||
$io->text('Downloading latest version...');
|
||||
$downloadUrl = 'https://github.com/leantime/leantime/releases/download/'.$latestVersion.'/Leantime-'.$latestVersion.'.zip';
|
||||
$zipFile = storage_path('/framework/cache/latest.zip');
|
||||
Http::sink($zipFile)->get($downloadUrl);
|
||||
|
||||
$io->text('Extracting Archive...');
|
||||
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$io->text('ZipArchive not found. Cannot auto-update until the php zip extension is installed. On linux, \'sudo apt install php-zip\'');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($zipFile) === true) {
|
||||
$zip->extractTo(storage_path('/framework/cache/leantime'));
|
||||
$zip->close();
|
||||
$io->success('New update zip file successfully extracted to '.storage_path('/framework/cache/leantime'));
|
||||
} else {
|
||||
$io->text('Error opening downloaded zip file!');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Disable Plugins + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
|
||||
// If we got here everything is ready to go and we just need to move the files.
|
||||
// Let's disable plugins
|
||||
$io->section('Disabling Plugins');
|
||||
|
||||
/** @var Plugins $plugins */
|
||||
$plugins = app()->make(Plugins::class);
|
||||
$enabledPlugins = $plugins->getAllPlugins(enabledOnly: true);
|
||||
foreach ($enabledPlugins as $plugin) {
|
||||
if ($plugin->type != 'system' && isset($plugin->id)) {
|
||||
$plugins->disablePlugin($plugin->id);
|
||||
$io->text($plugin->name.': Disabled');
|
||||
}
|
||||
}
|
||||
|
||||
$io->success('Plugins disabled successfully');
|
||||
|
||||
// Applying Update + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
|
||||
$io->section('Applying Update');
|
||||
$cp_retval = 0;
|
||||
$cp_output = [];
|
||||
exec('cp -r '.storage_path('/framework/cache/leantime').'/* '.APP_ROOT.'/', $cp_output, $cp_retval);
|
||||
$io->text('Returned with status '.$cp_retval.' and output:');
|
||||
$io->text($cp_output);
|
||||
|
||||
if ($cp_retval == 0) {
|
||||
$io->success('Files were updated');
|
||||
} else {
|
||||
$io->error('Could not apply update. Please check the output above for more information.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Clear Cache + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
|
||||
$io->section('Clearing Cache');
|
||||
|
||||
// Deliberately NOT a shell glob: inside the double quotes the shell never expanded
|
||||
// `*.php`, so this delete silently did nothing for years. A stale
|
||||
// bootstrap/cache/packages.php then survives updates and Laravel trusts it as-is —
|
||||
// any provider added to composer.json since (e.g. laravel/mcp) never loads.
|
||||
foreach (glob(APP_ROOT.'/bootstrap/cache/*.php') ?: [] as $cachedFile) {
|
||||
unlink($cachedFile);
|
||||
}
|
||||
exec('rm -rf "'.APP_ROOT.'/storage/framework/composerPaths.php"');
|
||||
exec('rm -rf "'.APP_ROOT.'/storage/framework/viewPaths.php"');
|
||||
|
||||
exec('rm -rf "'.APP_ROOT.'/storage/framework/cache/leantime"');
|
||||
exec('rm -rf "'.APP_ROOT.'/storage/framework/cache/latest.zip"');
|
||||
|
||||
exec('find "'.APP_ROOT.'/storage/framework/cache" -type f ! -name ".gitignore" -delete');
|
||||
exec('find "'.APP_ROOT.'/storage/framework/sessions" -type f ! -name ".gitignore" -delete');
|
||||
exec('find "'.APP_ROOT.'/storage/framework/views" -type f ! -name ".gitignore" -delete');
|
||||
|
||||
$io->success('Clearing Cache Complete');
|
||||
|
||||
// Enable Plugins + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
|
||||
$io->section('Re-enabling Plugins');
|
||||
foreach ($enabledPlugins as $plugin) {
|
||||
if ($plugin->type != 'system' && isset($plugin->id)) {
|
||||
$plugins->enablePlugin($plugin->id);
|
||||
$io->text($plugin->name.': Enabled');
|
||||
}
|
||||
}
|
||||
|
||||
$io->success('Plugins were enabled');
|
||||
|
||||
$io->section('Summary');
|
||||
$io->success('Update applied Successfully');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
120
app/Core/Application.php
Normal file
120
app/Core/Application.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core;
|
||||
|
||||
use Illuminate\Log\Context\ContextServiceProvider;
|
||||
use Illuminate\Log\LogServiceProvider;
|
||||
use Illuminate\Routing\RoutingServiceProvider;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Console\ConsoleKernel;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\ApiRequest;
|
||||
use Leantime\Core\Http\HttpKernel;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
|
||||
/**
|
||||
* Class Application
|
||||
*
|
||||
* Represents an application.
|
||||
*/
|
||||
class Application extends \Illuminate\Foundation\Application
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* Constructor for the class.
|
||||
*
|
||||
* @param string $basePath The base path for the application.
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($basePath = null)
|
||||
{
|
||||
|
||||
$this->namespace = 'Leantime\\';
|
||||
|
||||
if ($basePath) {
|
||||
$this->setBasePath($basePath);
|
||||
}
|
||||
|
||||
// Our folder structure is different and we shall not bow to the bourgeoisie
|
||||
$this->useAppPath($this->basePath.'/app');
|
||||
$this->useConfigPath($this->basePath.'/config');
|
||||
$this->useEnvironmentPath($this->basePath.'/config');
|
||||
$this->useBootstrapPath($this->basePath.'/bootstrap');
|
||||
$this->usePublicPath($this->basePath.'/public');
|
||||
$this->useStoragePath($this->basePath.'/storage');
|
||||
$this->useLangPath($this->basePath.'/app/Language');
|
||||
$this->useDatabasePath($this->basePath.'/database');
|
||||
|
||||
$this->registerBaseBindings();
|
||||
$this->registerBaseServiceProviders();
|
||||
|
||||
$this->registerCoreContainerAliases();
|
||||
// Overriding some of the aliases
|
||||
$this->registerLeantimeAliases();
|
||||
|
||||
$this->registerLaravelCloudServices();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the base service providers.
|
||||
*
|
||||
* This method is used to register the base service providers required for the application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerBaseServiceProviders()
|
||||
{
|
||||
// Loading some config vars so we can run events
|
||||
// $this->register(new \Leantime\Core\Providers\Environment($this));
|
||||
|
||||
$this->register(new Events\EventsServiceProvider($this));
|
||||
$this->register(new LogServiceProvider($this));
|
||||
$this->register(new ContextServiceProvider($this));
|
||||
$this->register(new RoutingServiceProvider($this));
|
||||
|
||||
// Todo: Add event and see if that works here.
|
||||
|
||||
}
|
||||
|
||||
public function registerLeantimeAliases()
|
||||
{
|
||||
foreach ([
|
||||
'app' => [self::class, \Illuminate\Contracts\Container\Container::class, Application::class, \Illuminate\Contracts\Foundation\Application::class, \Psr\Container\ContainerInterface::class],
|
||||
'config' => [Environment::class, \Illuminate\Config\Repository::class, \Illuminate\Contracts\Config\Repository::class],
|
||||
'request' => [IncomingRequest::class, ApiRequest::class, \Leantime\Core\Console\CliRequest::class, \Illuminate\Http\Request::class, \Symfony\Component\HttpFoundation\Request::class],
|
||||
] as $key => $aliases) {
|
||||
foreach ($aliases as $alias) {
|
||||
$this->alias($key, $alias);
|
||||
}
|
||||
}
|
||||
|
||||
$this->alias(HttpKernel::class, \Illuminate\Foundation\Http\Kernel::class);
|
||||
$this->alias(ConsoleKernel::class, \Illuminate\Foundation\Console\Kernel::class);
|
||||
|
||||
}
|
||||
|
||||
// Boot with Leantime event dispatcher
|
||||
|
||||
/**
|
||||
* Boot the application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
|
||||
// We need to discover events a lot earlier than Laravel wants us to.
|
||||
// So we're just doing it.
|
||||
\Illuminate\Support\Facades\Event::discoverListeners();
|
||||
|
||||
// Calling the first event
|
||||
self::dispatchEvent('beforeBootingServiceProviders');
|
||||
|
||||
parent::boot();
|
||||
|
||||
self::dispatchEvent('afterBootingServiceProviders');
|
||||
|
||||
}
|
||||
}
|
||||
30
app/Core/Application/AppServiceProvider.php
Normal file
30
app/Core/Application/AppServiceProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Application;
|
||||
|
||||
use Illuminate\Foundation\Console\AboutCommand;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
AboutCommand::add('Environment', [
|
||||
'Leantime App Version' => fn () => $this->app->make(AppSettings::class)->appVersion,
|
||||
'Leantime Db Version' => fn () => $this->app->make(AppSettings::class)->dbVersion,
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
133
app/Core/Auth/AuthenticationServiceProvider.php
Normal file
133
app/Core/Auth/AuthenticationServiceProvider.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth;
|
||||
|
||||
use Illuminate\Auth\Access\Gate;
|
||||
use Illuminate\Auth\Middleware\RequirePassword;
|
||||
use Illuminate\Contracts\Auth\Access\Gate as GateContract;
|
||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
||||
use Illuminate\Contracts\Routing\ResponseFactory;
|
||||
use Illuminate\Contracts\Routing\UrlGenerator;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Leantime\Domain\Auth\Guards\ApiGuard;
|
||||
use Leantime\Domain\Auth\Guards\WebGuard;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Auth\Services\AuthUser;
|
||||
use Leantime\Domain\Oidc\Services\Oidc as OidcService;
|
||||
|
||||
class AuthenticationServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
// $this->app->singleton(AuthService::class, AuthService::class);
|
||||
// $this->app->singleton(OidcService::class, OidcService::class);
|
||||
|
||||
$this->registerAuthenticator();
|
||||
$this->registerUserResolver();
|
||||
$this->registerAccessGate();
|
||||
$this->registerRequirePassword();
|
||||
$this->registerRequestRebindHandler();
|
||||
$this->registerEventRebindHandler();
|
||||
}
|
||||
|
||||
protected function registerAuthenticator()
|
||||
{
|
||||
$this->app->singleton('auth', function ($app) {
|
||||
return new \Illuminate\Auth\AuthManager($app);
|
||||
});
|
||||
|
||||
$this->app->singleton('auth.driver', fn ($app) => $app['auth']->guard());
|
||||
|
||||
}
|
||||
|
||||
protected function registerUserResolver()
|
||||
{
|
||||
$this->app->bind(AuthenticatableContract::class, fn ($app) => call_user_func($app['auth']->userResolver()));
|
||||
}
|
||||
|
||||
protected function registerAccessGate()
|
||||
{
|
||||
$this->app->singleton(GateContract::class, function ($app) {
|
||||
return new Gate($app, fn () => call_user_func($app['auth']->userResolver()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a resolver for the authenticated user.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerRequirePassword()
|
||||
{
|
||||
$this->app->bind(RequirePassword::class, function ($app) {
|
||||
return new RequirePassword(
|
||||
$app[ResponseFactory::class],
|
||||
$app[UrlGenerator::class],
|
||||
$app['config']->get('auth.password_timeout')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the re-binding of the request binding.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerRequestRebindHandler()
|
||||
{
|
||||
$this->app->rebinding('request', function ($app, $request) {
|
||||
$request->setUserResolver(function ($guard = null) use ($app) {
|
||||
return call_user_func($app['auth']->userResolver(), $guard);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the re-binding of the event dispatcher binding.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerEventRebindHandler()
|
||||
{
|
||||
$this->app->rebinding('events', function ($app, $dispatcher) {
|
||||
if (! $app->resolved('auth') ||
|
||||
$app['auth']->hasResolvedGuards() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (method_exists($guard = $app['auth']->guard(), 'setDispatcher')) {
|
||||
$guard->setDispatcher($dispatcher);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function boot()
|
||||
{
|
||||
|
||||
$this->app['auth']->provider('leantimeUsers', function ($app, array $config) {
|
||||
return new AuthUser(
|
||||
$app->make(\Leantime\Domain\Auth\Services\Auth::class)
|
||||
);
|
||||
});
|
||||
|
||||
$this->app['auth']->extend('leantime', function ($app, $name, array $config) {
|
||||
return new WebGuard(
|
||||
$app['auth']->createUserProvider($config['provider']),
|
||||
$app->make(\Leantime\Domain\Auth\Services\Auth::class)
|
||||
);
|
||||
});
|
||||
|
||||
$this->app['auth']->extend('jsonRpc', function ($app, $name, array $config) {
|
||||
return new ApiGuard(
|
||||
$app['auth']->createUserProvider($config['provider']),
|
||||
$app->make(\Leantime\Domain\Api\Services\Api::class),
|
||||
$app['request']
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
26
app/Core/Auth/Contracts/ChecksProjectAccess.php
Normal file
26
app/Core/Auth/Contracts/ChecksProjectAccess.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Contracts;
|
||||
|
||||
/**
|
||||
* Narrow contract the permission engine depends on for project-level data access and the
|
||||
* per-project role, implemented by the Projects domain service.
|
||||
*
|
||||
* The engine lives in Core and must not depend on the 2,900-line Projects god-service
|
||||
* directly; it depends on this abstraction (bound to Projects in PermissionServiceProvider),
|
||||
* which also keeps the surface small and sidesteps circular-reference risk.
|
||||
*/
|
||||
interface ChecksProjectAccess
|
||||
{
|
||||
/**
|
||||
* Whether the user can access the given project (assigned, or via the project's
|
||||
* `psettings` — admin/owner bypass handled by the implementation/engine).
|
||||
*/
|
||||
public function isUserAssignedToProject(int $userId, int $projectId): bool;
|
||||
|
||||
/**
|
||||
* The user's explicit role within the project, or '' when none is set (inherits global).
|
||||
* Returns the stored role key as a string.
|
||||
*/
|
||||
public function getProjectRole(int $userId, int $projectId): string;
|
||||
}
|
||||
40
app/Core/Auth/Permissions/CheckPermissions.php
Normal file
40
app/Core/Auth/Permissions/CheckPermissions.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Enforces #[RequiresPermission] for native Laravel-routed controllers (Blueprints, the
|
||||
* relocated image/upload controllers, etc.) — the controllers that do NOT go through
|
||||
* Frontcontroller. Applied to all domain/plugin routes by {@see \Leantime\Core\Routing\RouteLoader}.
|
||||
*
|
||||
* Reads the attribute off the matched route's controller@method via the shared
|
||||
* {@see PermissionEnforcer} (injected, not resolved through the app() helper), so the
|
||||
* attribute remains the single source of truth — no per-route `can:` duplication. A method
|
||||
* without the attribute is a no-op.
|
||||
*/
|
||||
class CheckPermissions
|
||||
{
|
||||
public function __construct(private PermissionEnforcer $enforcer) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$route = $request->route();
|
||||
|
||||
if ($route instanceof \Illuminate\Routing\Route) {
|
||||
$controller = $route->getControllerClass();
|
||||
$method = $route->getActionMethod();
|
||||
|
||||
// Skip closure routes (no controller) and invokable/closure actions where the
|
||||
// "method" resolves to the class name itself.
|
||||
if (is_string($controller) && $controller !== '' && is_string($method) && $method !== $controller) {
|
||||
$this->enforcer->enforce($controller, $method, $request->all());
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
187
app/Core/Auth/Permissions/DefaultRolePermissions.php
Normal file
187
app/Core/Auth/Permissions/DefaultRolePermissions.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The single, central definition of the six built-in roles and the permissions they hold
|
||||
* by default — the permission-era equivalent of a Spatie roles-and-permissions seeder.
|
||||
*
|
||||
* This is the ONLY place built-in role→permission assignment lives. Domains declare verbs
|
||||
* ({@see ProvidesPermissions}); this maps those verbs onto roles. After install the
|
||||
* `zp_role_permissions` table is the runtime source of truth and the admin UI edits it —
|
||||
* this class only provides the initial defaults.
|
||||
*
|
||||
* Defaults are expressed as incremental grant rules per role and **unioned up the
|
||||
* hierarchy** (a role inherits every lower role's grants), so the rules read as deltas:
|
||||
* - readonly : view project content
|
||||
* - commenter : + comment / upload
|
||||
* - editor : + create / edit / delete
|
||||
* - manager : + everything else on project content (e.g. project settings)
|
||||
* - admin : + all company-wide capabilities, except company settings
|
||||
* - owner : + everything (incl. company settings)
|
||||
*
|
||||
* A rule matches a {@see Permission} by scope (project- vs company-scoped), by verb (the
|
||||
* last dotted segment, or `*` for all), minus any excluded keys/prefixes.
|
||||
*/
|
||||
final class DefaultRolePermissions
|
||||
{
|
||||
/**
|
||||
* Built-in roles, ordered low→high. `level` preserves the legacy hierarchy weight.
|
||||
*
|
||||
* @var array<int, array{name: string, displayName: string, level: int}>
|
||||
*/
|
||||
private const ROLES = [
|
||||
['name' => 'readonly', 'displayName' => 'Read Only', 'level' => 5],
|
||||
['name' => 'commenter', 'displayName' => 'Commenter', 'level' => 10],
|
||||
['name' => 'editor', 'displayName' => 'Editor', 'level' => 20],
|
||||
['name' => 'manager', 'displayName' => 'Company Manager', 'level' => 30],
|
||||
['name' => 'admin', 'displayName' => 'Admin', 'level' => 40],
|
||||
['name' => 'owner', 'displayName' => 'Owner', 'level' => 50],
|
||||
];
|
||||
|
||||
/**
|
||||
* Incremental default grants per role (unioned up the hierarchy by {@see grantsFor()}).
|
||||
*
|
||||
* Each rule: scope = project|global|any; verbs = list of last-segment verbs or ['*'];
|
||||
* exclude = exact keys or 'prefix.*' globs removed from the match.
|
||||
*
|
||||
* A rule grants by `verbs` (the convention) OR by explicit `keys` (for permissions that
|
||||
* don't follow the verb convention).
|
||||
*
|
||||
* @var array<string, array<int, array{scope: string, verbs?: array<int, string>, keys?: array<int, string>, exclude?: array<int, string>}>>
|
||||
*/
|
||||
private const GRANTS = [
|
||||
'readonly' => [['scope' => 'project', 'verbs' => ['view']]],
|
||||
'commenter' => [
|
||||
['scope' => 'project', 'verbs' => ['comment', 'upload']],
|
||||
// Commenting via the Comments domain: the 'create' verb otherwise seeds at
|
||||
// editor+, but a commenter is allowed to add comments — grant the key explicitly.
|
||||
['scope' => 'project', 'keys' => ['comments.create']],
|
||||
],
|
||||
'editor' => [
|
||||
['scope' => 'project', 'verbs' => ['create', 'edit', 'delete']],
|
||||
// Timesheets are GLOBAL-scoped (company-wide time logging), so the project verb rule
|
||||
// above does NOT match them — an editor's own-time capability is granted by explicit
|
||||
// global keys. Ownership (own vs others) is enforced in the service; the cross-user
|
||||
// `timesheets.manage` stays manager+ (below).
|
||||
['scope' => 'global', 'keys' => ['timesheets.view', 'timesheets.create', 'timesheets.edit', 'timesheets.delete']],
|
||||
],
|
||||
'manager' => [
|
||||
['scope' => 'project', 'verbs' => ['*']],
|
||||
// Managers may INVITE users (the NewUser screen is manager+). The client-scoping
|
||||
// — a manager can only invite into their own client — stays in the controller/
|
||||
// service, not here. They CANNOT view the roster, edit, delete, or import users;
|
||||
// those remain admin+. users.* are company-wide, so this is an explicit global
|
||||
// key grant rather than a 'create' verb rule (a verb rule would also need a global
|
||||
// scope and is fine, but the explicit key documents that ONLY create is intended).
|
||||
// timesheets.manage (company-wide invoicing/reports/others' time) is manager+; the
|
||||
// editor keys above are inherited up the hierarchy.
|
||||
//
|
||||
// projects.create/edit/delete are GLOBAL-scoped company actions (managers create/edit/
|
||||
// delete ANY project — the legacy controllers gate on the global manager role via
|
||||
// authOrRedirect([...], forceGlobalRoleCheck: true)). Being global, they are NOT matched
|
||||
// by the editor's `scope:project create/edit/delete` rule, so they stay manager+ here;
|
||||
// projects.view is a project-scoped verb and auto-grants readonly+ separately.
|
||||
['scope' => 'global', 'keys' => ['users.create', 'timesheets.manage', 'projects.create', 'projects.edit', 'projects.delete']],
|
||||
],
|
||||
'admin' => [
|
||||
['scope' => 'any', 'verbs' => ['*'], 'exclude' => ['company.settings.*']],
|
||||
// Admins may view AND edit the company-settings screen (incl. logo) per policy. The
|
||||
// exclude above keeps any OTHER future company.settings.* owner-only by default;
|
||||
// these two keys are granted to admins explicitly.
|
||||
['scope' => 'global', 'keys' => ['company.settings.view', 'company.settings.edit']],
|
||||
],
|
||||
'owner' => [['scope' => 'any', 'verbs' => ['*']]],
|
||||
];
|
||||
|
||||
/** @return array<int, array{name: string, displayName: string, level: int}> */
|
||||
public static function roles(): array
|
||||
{
|
||||
return self::ROLES;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default permission keys granted to $roleName, given the full discovered catalog.
|
||||
* Unions this role's rules with every lower role in the hierarchy.
|
||||
*
|
||||
* @param array<int, Permission> $catalog
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function grantsFor(string $roleName, array $catalog): array
|
||||
{
|
||||
$level = self::levelOf($roleName);
|
||||
|
||||
$rules = [];
|
||||
foreach (self::ROLES as $role) {
|
||||
if ($role['level'] <= $level) {
|
||||
foreach (self::GRANTS[$role['name']] as $rule) {
|
||||
$rules[] = $rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
foreach ($catalog as $permission) {
|
||||
foreach ($rules as $rule) {
|
||||
if (self::matches($permission, $rule)) {
|
||||
$keys[$permission->key] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($keys);
|
||||
}
|
||||
|
||||
private static function levelOf(string $roleName): int
|
||||
{
|
||||
foreach (self::ROLES as $role) {
|
||||
if ($role['name'] === $roleName) {
|
||||
return $role['level'];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{scope: string, verbs?: array<int, string>, keys?: array<int, string>, exclude?: array<int, string>} $rule
|
||||
*/
|
||||
private static function matches(Permission $permission, array $rule): bool
|
||||
{
|
||||
if ($rule['scope'] === 'project' && ! $permission->projectScoped) {
|
||||
return false;
|
||||
}
|
||||
if ($rule['scope'] === 'global' && $permission->projectScoped) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A rule matches by EITHER an explicit key allow-list (for permissions that don't follow
|
||||
// the verb convention) OR by verb. Compute the base match first...
|
||||
if (isset($rule['keys'])) {
|
||||
$matched = in_array($permission->key, $rule['keys'], true);
|
||||
} else {
|
||||
$verb = Str::afterLast($permission->key, '.');
|
||||
$matched = ($rule['verbs'] ?? []) === ['*'] || in_array($verb, $rule['verbs'] ?? [], true);
|
||||
}
|
||||
|
||||
if (! $matched) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ...then ALWAYS apply the exclude list, so an `exclude` alongside `keys` is honored (a
|
||||
// `keys` rule previously returned early and bypassed the exclude, risking an over-grant).
|
||||
foreach ($rule['exclude'] ?? [] as $excluded) {
|
||||
if ($excluded === $permission->key) {
|
||||
return false;
|
||||
}
|
||||
if (str_ends_with($excluded, '.*') && str_starts_with($permission->key, substr($excluded, 0, -1))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
52
app/Core/Auth/Permissions/Permission.php
Normal file
52
app/Core/Auth/Permissions/Permission.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Immutable description of a single capability in the permission vocabulary.
|
||||
*
|
||||
* A permission is just a named, dotted `domain.action` verb (e.g. `tickets.create`,
|
||||
* `company.settings.edit`) plus presentation/scope metadata. Crucially it carries **no
|
||||
* role information** — which roles hold a permission is a separate, centrally-managed
|
||||
* concern (see {@see DefaultRolePermissions} for the built-in defaults and the
|
||||
* `zp_role_permissions` table / admin UI for runtime assignments). A domain declares only
|
||||
* *what verbs exist*; it never declares *who gets them*.
|
||||
*
|
||||
* `projectScoped` distinguishes capabilities evaluated against a specific project's role
|
||||
* (most content actions) from company-wide capabilities (user/client management, company
|
||||
* settings) that resolve against the global role.
|
||||
*/
|
||||
final class Permission
|
||||
{
|
||||
/**
|
||||
* @param string $key Dotted `domain.action` identifier, e.g. `tickets.create`.
|
||||
* @param string $displayName Human-readable label shown in the role/permission UI.
|
||||
* @param bool $projectScoped Whether this capability is evaluated per-project (true)
|
||||
* or company-wide against the global role (false).
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $key,
|
||||
public readonly string $displayName,
|
||||
public readonly bool $projectScoped = true,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The owning domain — the first dotted segment of the key (e.g. `company` for
|
||||
* `company.settings.edit`).
|
||||
*/
|
||||
public function domain(): string
|
||||
{
|
||||
return Str::before($this->key, '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* The action — everything after the first dotted segment (e.g. `settings.edit`
|
||||
* for `company.settings.edit`, `create` for `tickets.create`).
|
||||
*/
|
||||
public function action(): string
|
||||
{
|
||||
return Str::after($this->key, '.');
|
||||
}
|
||||
}
|
||||
239
app/Core/Auth/Permissions/PermissionEnforcer.php
Normal file
239
app/Core/Auth/Permissions/PermissionEnforcer.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use ReflectionException;
|
||||
use ReflectionMethod;
|
||||
|
||||
/**
|
||||
* Reads the {@see RequiresPermission} attribute off a resolved action/method and enforces it.
|
||||
*
|
||||
* Shared by the entry points so the declaration means the same everywhere:
|
||||
* - {@see \Leantime\Core\Controller\Frontcontroller::executeAction()} — legacy convention routes,
|
||||
* - {@see CheckPermissions} middleware — native Laravel routes,
|
||||
* - {@see \Leantime\Domain\Api\Controllers\Jsonrpc::executeApiRequest()} — JSON-RPC.
|
||||
*
|
||||
* Safety properties:
|
||||
* - A method WITHOUT the attribute is a complete no-op — it never touches the session, DB,
|
||||
* or permission engine. So wiring the hooks in is inert until methods are annotated.
|
||||
* - Audit mode (the default, `config('permissions.enforce')` falsy) only LOGS would-be
|
||||
* denials instead of blocking, so enforcement can be rolled out and observed per domain
|
||||
* before flipping to blocking.
|
||||
*/
|
||||
class PermissionEnforcer
|
||||
{
|
||||
/** @var array<string, RequiresPermission|null> Memoized attribute lookups per class::method. */
|
||||
private array $cache = [];
|
||||
|
||||
/** @var array<string, bool> Memoized "is this param mandatory" lookups per class::method::param. */
|
||||
private array $mandatoryParamCache = [];
|
||||
|
||||
public function __construct(private PermissionService $permissions) {}
|
||||
|
||||
/**
|
||||
* Enforce the permission required by $class::$method, if any.
|
||||
*
|
||||
* @param object|class-string $class The controller instance or service class name.
|
||||
* @param array<string, mixed> $params Request/method parameters (for project-id resolution).
|
||||
*
|
||||
* @throws AuthorizationException When denied and not in audit mode.
|
||||
*/
|
||||
public function enforce(object|string $class, string $method, array $params = []): void
|
||||
{
|
||||
$attribute = $this->attributeFor($class, $method);
|
||||
|
||||
if ($attribute === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Entity-scoped: the method loads the entity and authorizes its project in its own
|
||||
// body (the enforcer can't see the entity's project here). The attribute is just the
|
||||
// declared-coverage marker; defer to the in-method $this->authorize() call.
|
||||
if ($attribute->entityScoped) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reason = '';
|
||||
|
||||
if ($attribute->global) {
|
||||
$allowed = $this->permissions->currentUserCan($attribute->permission, null, true);
|
||||
} else {
|
||||
$projectId = $this->resolveProjectId($attribute, $class, $method, $params);
|
||||
|
||||
if ($projectId === false) {
|
||||
// A declared projectIdParam that can't be resolved to a concrete project, on a
|
||||
// method whose signature makes that param mandatory, fails closed: we cannot
|
||||
// identify which project to authorize against, and silently falling back to the
|
||||
// session project would authorize the wrong one. Optional params keep the
|
||||
// session fallback (see resolveProjectId).
|
||||
$allowed = false;
|
||||
$reason = sprintf(' (unresolved mandatory project param "%s")', $attribute->projectIdParam);
|
||||
} else {
|
||||
$allowed = $this->permissions->currentUserCan($attribute->permission, $projectId);
|
||||
}
|
||||
}
|
||||
|
||||
if ($allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$target = (is_object($class) ? $class::class : $class).'::'.$method;
|
||||
$user = session('userdata.id') ?? 'guest';
|
||||
|
||||
if (! $this->shouldBlock()) {
|
||||
Log::info(sprintf('[permissions:audit] would deny "%s" on %s for user %s%s', $attribute->permission, $target, $user, $reason));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the permission key server-side for audit; the thrown exception stays generic
|
||||
// so the authorization vocabulary is never exposed to the client.
|
||||
Log::info(sprintf('Authorization denied: "%s" on %s for user %s%s', $attribute->permission, $target, $user, $reason));
|
||||
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RequiresPermission attribute on $class::$method, or null. Memoized; tolerant of
|
||||
* missing methods (returns null) so it can guard any dispatch target.
|
||||
*/
|
||||
private function attributeFor(object|string $class, string $method): ?RequiresPermission
|
||||
{
|
||||
$className = is_object($class) ? $class::class : $class;
|
||||
$key = $className.'::'.$method;
|
||||
|
||||
if (array_key_exists($key, $this->cache)) {
|
||||
return $this->cache[$key];
|
||||
}
|
||||
|
||||
$attribute = null;
|
||||
|
||||
try {
|
||||
$attributes = (new ReflectionMethod($className, $method))->getAttributes(RequiresPermission::class);
|
||||
|
||||
if ($attributes !== []) {
|
||||
$attribute = $attributes[0]->newInstance();
|
||||
}
|
||||
} catch (ReflectionException) {
|
||||
$attribute = null;
|
||||
}
|
||||
|
||||
return $this->cache[$key] = $attribute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project id for a project-scoped check.
|
||||
*
|
||||
* Three outcomes:
|
||||
* - int — a concrete project to authorize against (the declared param, or the session
|
||||
* project for attributes that declare no param);
|
||||
* - null — no concrete project and none required (the session project was empty on an
|
||||
* attribute that allows the fallback): the engine checks capability only;
|
||||
* - false — DENY. The attribute declares a projectIdParam, the value is absent/null/zero,
|
||||
* AND the target method's signature makes that param mandatory (no default).
|
||||
* We can't identify the project and the method can't run without it, so we fail
|
||||
* closed instead of authorizing against the unrelated session project.
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function resolveProjectId(RequiresPermission $attribute, object|string $class, string $method, array $params): int|false|null
|
||||
{
|
||||
// No declared param: scope to the ambient session project (session-scoped views).
|
||||
if ($attribute->projectIdParam === null) {
|
||||
return $this->sessionProject();
|
||||
}
|
||||
|
||||
$name = $attribute->projectIdParam;
|
||||
|
||||
// Declared param present and a real positive integer: scope to it. Anything else — a
|
||||
// missing/null value, a non-numeric or non-positive string, or a non-scalar like the
|
||||
// array from `projectId[]=7` (which a bare `(int)` cast would silently turn into 1) — is
|
||||
// treated as unresolved and falls through to the mandatory check below.
|
||||
$resolved = $this->positiveInt($params[$name] ?? null);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
// Declared but unresolved. Fail closed only when the method proves the project is
|
||||
// mandatory; methods that default the project (e.g. poll/dashboard "current project"
|
||||
// endpoints) legitimately mean "the session project" and keep the fallback.
|
||||
if ($this->paramIsMandatory($class, $method, $name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->sessionProject();
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a request value to a positive project id, or null if it doesn't represent one.
|
||||
* Strict on purpose — this gates authorization, so anything that isn't an in-range positive
|
||||
* integer is rejected rather than cast.
|
||||
*/
|
||||
private function positiveInt(mixed $value): ?int
|
||||
{
|
||||
// Only an int or a string can name a project id — reject arrays/floats/bools/null outright.
|
||||
if (! is_int($value) && ! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// FILTER_VALIDATE_INT rejects non-numeric strings AND out-of-range values; a bare (int)
|
||||
// cast would instead saturate a giant all-digits string to PHP_INT_MAX and treat it as a
|
||||
// real (wrong) project id.
|
||||
$int = filter_var($value, FILTER_VALIDATE_INT);
|
||||
|
||||
return ($int !== false && $int > 0) ? $int : null;
|
||||
}
|
||||
|
||||
/** The current session project as an int, or null when none/zero is set. */
|
||||
private function sessionProject(): ?int
|
||||
{
|
||||
$current = session('currentProject');
|
||||
|
||||
return ($current === null || (int) $current === 0) ? null : (int) $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $paramName on $class::$method is mandatory — i.e. has no default value, so a
|
||||
* caller cannot legitimately omit it. Mirrors the JSON-RPC dispatcher's own "required"
|
||||
* definition ({@see \Leantime\Domain\Api\Controllers\Jsonrpc::prepareParameters()}:
|
||||
* `! isDefaultValueAvailable()`) so the two stay in lockstep. Memoized; tolerant of a
|
||||
* missing method/param (returns false → keep the session fallback) so it never over-denies
|
||||
* a target it can't reflect (e.g. a controller action taking a single $params array).
|
||||
*/
|
||||
private function paramIsMandatory(object|string $class, string $method, string $paramName): bool
|
||||
{
|
||||
$className = is_object($class) ? $class::class : $class;
|
||||
$key = $className.'::'.$method.'::'.$paramName;
|
||||
|
||||
if (array_key_exists($key, $this->mandatoryParamCache)) {
|
||||
return $this->mandatoryParamCache[$key];
|
||||
}
|
||||
|
||||
$mandatory = false;
|
||||
|
||||
try {
|
||||
foreach ((new ReflectionMethod($className, $method))->getParameters() as $param) {
|
||||
if ($param->getName() === $paramName) {
|
||||
$mandatory = ! $param->isDefaultValueAvailable();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (ReflectionException) {
|
||||
$mandatory = false;
|
||||
}
|
||||
|
||||
return $this->mandatoryParamCache[$key] = $mandatory;
|
||||
}
|
||||
|
||||
/** Whether denials block (true) or are only logged (false, the default — audit mode). */
|
||||
private function shouldBlock(): bool
|
||||
{
|
||||
try {
|
||||
return (bool) config('permissions.enforce', false);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
122
app/Core/Auth/Permissions/PermissionRegistry.php
Normal file
122
app/Core/Auth/Permissions/PermissionRegistry.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Discovers and aggregates the permission vocabulary declared by every domain (and folder
|
||||
* plugin) implementing {@see ProvidesPermissions}.
|
||||
*
|
||||
* Mirrors {@see \Leantime\Core\Events\EventDispatcher::discoverListeners()}: it globs the
|
||||
* conventional locations, caches the discovered provider class list on the shared
|
||||
* `installation` store outside debug mode, then instantiates each provider and merges its
|
||||
* {@see Permission} declarations into a single keyed catalog. The catalog is the in-memory
|
||||
* source of truth that `permissions:sync` writes into the database.
|
||||
*/
|
||||
class PermissionRegistry
|
||||
{
|
||||
private const PROVIDER_CACHE_KEY = 'permissionProviders';
|
||||
|
||||
/** @var array<string, Permission>|null */
|
||||
private ?array $catalog = null;
|
||||
|
||||
public function __construct(private Container $container) {}
|
||||
|
||||
/**
|
||||
* The full catalog keyed by permission key (e.g. 'tickets.create' => Permission).
|
||||
*
|
||||
* @return array<string, Permission>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
if ($this->catalog !== null) {
|
||||
return $this->catalog;
|
||||
}
|
||||
|
||||
$this->catalog = [];
|
||||
|
||||
foreach ($this->providerClasses() as $class) {
|
||||
$provider = $this->container->make($class);
|
||||
|
||||
if (! $provider instanceof ProvidesPermissions) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($provider->permissions() as $permission) {
|
||||
$this->catalog[$permission->key] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->catalog;
|
||||
}
|
||||
|
||||
public function get(string $key): ?Permission
|
||||
{
|
||||
return $this->all()[$key] ?? null;
|
||||
}
|
||||
|
||||
/** Drop the in-memory and cross-request provider caches (call on plugin enable/disable). */
|
||||
public function flush(): void
|
||||
{
|
||||
$this->catalog = null;
|
||||
Cache::store('installation')->forget(self::PROVIDER_CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* The discovered provider class names. Cached on the installation store outside debug
|
||||
* mode, exactly like EventDispatcher's 'domainEvents'.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function providerClasses(): array
|
||||
{
|
||||
if ((bool) config('debug') === false) {
|
||||
return Cache::store('installation')->rememberForever(self::PROVIDER_CACHE_KEY, fn () => $this->scanProviderClasses());
|
||||
}
|
||||
|
||||
return $this->scanProviderClasses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob the conventional provider locations and resolve each to a FQCN.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function scanProviderClasses(): array
|
||||
{
|
||||
$patterns = [
|
||||
APP_ROOT.'/app/Domain/*/Permissions/*Permissions.php',
|
||||
APP_ROOT.'/app/Plugins/*/Permissions/*Permissions.php',
|
||||
];
|
||||
|
||||
$classes = [];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
foreach ((array) glob($pattern) as $file) {
|
||||
$class = $this->classFromPath((string) $file);
|
||||
|
||||
if ($class !== null) {
|
||||
$classes[] = $class;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an app file path to its FQCN under the Leantime namespace
|
||||
* (app/Domain/Tickets/Permissions/TicketsPermissions.php ->
|
||||
* Leantime\Domain\Tickets\Permissions\TicketsPermissions).
|
||||
*/
|
||||
private function classFromPath(string $file): ?string
|
||||
{
|
||||
$relative = str_replace(APP_ROOT.'/app/', '', $file);
|
||||
$relative = substr($relative, 0, -strlen('.php'));
|
||||
$class = 'Leantime\\'.str_replace('/', '\\', $relative);
|
||||
|
||||
return class_exists($class) ? $class : null;
|
||||
}
|
||||
}
|
||||
288
app/Core/Auth/Permissions/PermissionRepository.php
Normal file
288
app/Core/Auth/Permissions/PermissionRepository.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
|
||||
/**
|
||||
* Data access for the native permission engine (no ORM — Laravel query builder over the
|
||||
* `zp_roles`, `zp_permissions`, `zp_role_permissions` tables).
|
||||
*
|
||||
* Roles are the DB-backed definitions (built-ins + custom); permissions are the synced
|
||||
* `domain.action` vocabulary; the grant map links them. This repository is consumed by
|
||||
* {@see PermissionService} (read path, cached), {@see PermissionSeeder} (built-in seeding +
|
||||
* vocabulary sync), `permissions:sync`, and the future role-management UI (write path).
|
||||
*/
|
||||
class PermissionRepository
|
||||
{
|
||||
private ConnectionInterface $db;
|
||||
|
||||
public function __construct(DbCore $db)
|
||||
{
|
||||
$this->db = $db->getConnection();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Roles
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function getRoleByName(string $name): ?array
|
||||
{
|
||||
$row = $this->db->table('zp_roles')->where('name', $name)->first();
|
||||
|
||||
return $row ? (array) $row : null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function getRoleById(int $id): ?array
|
||||
{
|
||||
$row = $this->db->table('zp_roles')->where('id', $id)->first();
|
||||
|
||||
return $row ? (array) $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* All roles ordered by hierarchy level (ascending).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getAllRoles(): array
|
||||
{
|
||||
return $this->db->table('zp_roles')
|
||||
->orderBy('level')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a role keyed by its (unique) name. Returns the role id.
|
||||
* Built-in roles pass isSystem=true so the admin UI can protect them.
|
||||
*/
|
||||
public function upsertRole(string $name, string $displayName, int $level, bool $isSystem = false, ?string $description = null): int
|
||||
{
|
||||
$now = dtHelper()->userNow()->formatDateTimeForDb();
|
||||
$existing = $this->getRoleByName($name);
|
||||
|
||||
if ($existing !== null) {
|
||||
$this->db->table('zp_roles')->where('id', $existing['id'])->update([
|
||||
'displayName' => $displayName,
|
||||
'level' => $level,
|
||||
'isSystem' => $isSystem ? 1 : 0,
|
||||
'description' => $description,
|
||||
'modified' => $now,
|
||||
]);
|
||||
|
||||
return (int) $existing['id'];
|
||||
}
|
||||
|
||||
return (int) $this->db->table('zp_roles')->insertGetId([
|
||||
'name' => $name,
|
||||
'displayName' => $displayName,
|
||||
'level' => $level,
|
||||
'isSystem' => $isSystem ? 1 : 0,
|
||||
'description' => $description,
|
||||
'createdOn' => $now,
|
||||
'modified' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function createRole(array $data): int
|
||||
{
|
||||
return $this->upsertRole(
|
||||
(string) $data['name'],
|
||||
(string) ($data['displayName'] ?? $data['name']),
|
||||
(int) ($data['level'] ?? 20),
|
||||
(bool) ($data['isSystem'] ?? false),
|
||||
$data['description'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function updateRole(int $id, array $data): bool
|
||||
{
|
||||
$data['modified'] = dtHelper()->userNow()->formatDateTimeForDb();
|
||||
|
||||
return (bool) $this->db->table('zp_roles')->where('id', $id)->update($data);
|
||||
}
|
||||
|
||||
/** Deletes a role and its grants. Callers must block deletion of isSystem roles. */
|
||||
public function deleteRole(int $id): bool
|
||||
{
|
||||
$this->db->table('zp_role_permissions')->where('roleId', $id)->delete();
|
||||
|
||||
return (bool) $this->db->table('zp_roles')->where('id', $id)->delete();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Permissions (the vocabulary)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function getAllPermissions(): array
|
||||
{
|
||||
return $this->db->table('zp_permissions')
|
||||
->orderBy('permissionKey')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function getPermissionByKey(string $key): ?array
|
||||
{
|
||||
$row = $this->db->table('zp_permissions')->where('permissionKey', $key)->first();
|
||||
|
||||
return $row ? (array) $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently upsert the discovered vocabulary into zp_permissions, keyed by
|
||||
* permissionKey. Returns the full list of synced keys (for optional pruning).
|
||||
*
|
||||
* @param array<int, array{key:string, domain:string, action:string, label:string, projectScoped:bool}> $definitions
|
||||
* @return array<int, string> The synced permission keys.
|
||||
*/
|
||||
public function syncPermissions(array $definitions): array
|
||||
{
|
||||
$now = dtHelper()->userNow()->formatDateTimeForDb();
|
||||
$keys = [];
|
||||
|
||||
foreach ($definitions as $def) {
|
||||
$keys[] = $def['key'];
|
||||
$row = [
|
||||
'domain' => $def['domain'],
|
||||
'action' => $def['action'],
|
||||
'label' => $def['label'],
|
||||
'isProjectScoped' => $def['projectScoped'] ? 1 : 0,
|
||||
'modified' => $now,
|
||||
];
|
||||
|
||||
if ($this->getPermissionByKey($def['key']) !== null) {
|
||||
$this->db->table('zp_permissions')->where('permissionKey', $def['key'])->update($row);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->db->table('zp_permissions')->insert($row + [
|
||||
'permissionKey' => $def['key'],
|
||||
'createdOn' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove permissions (and their grants) whose key is not in $keepKeys. Returns the
|
||||
* number of pruned permissions. Used by `permissions:sync --prune`.
|
||||
*
|
||||
* @param array<int, string> $keepKeys
|
||||
*/
|
||||
public function pruneOrphanPermissions(array $keepKeys): int
|
||||
{
|
||||
$orphans = $this->db->table('zp_permissions')
|
||||
->when($keepKeys !== [], fn ($q) => $q->whereNotIn('permissionKey', $keepKeys))
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if ($orphans === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->db->table('zp_role_permissions')->whereIn('permissionId', $orphans)->delete();
|
||||
|
||||
return $this->db->table('zp_permissions')->whereIn('id', $orphans)->delete();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Grants (role <-> permission map)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The full role -> [permissionKey, ...] map driving runtime checks. One JOIN; the
|
||||
* caller ({@see PermissionService}) caches the result.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function getRolePermissionMap(): array
|
||||
{
|
||||
$rows = $this->db->table('zp_role_permissions as rp')
|
||||
->join('zp_roles as r', 'r.id', '=', 'rp.roleId')
|
||||
->join('zp_permissions as p', 'p.id', '=', 'rp.permissionId')
|
||||
->select('r.name as roleName', 'p.permissionKey as permissionKey')
|
||||
->get();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[$row->roleName][] = $row->permissionKey;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a role's grants with exactly the given permission keys (transactional).
|
||||
*
|
||||
* @param array<int, string> $permissionKeys
|
||||
*/
|
||||
public function replaceRolePermissions(int $roleId, array $permissionKeys): void
|
||||
{
|
||||
$this->db->transaction(function () use ($roleId, $permissionKeys) {
|
||||
$this->db->table('zp_role_permissions')->where('roleId', $roleId)->delete();
|
||||
|
||||
if ($permissionKeys === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = $this->db->table('zp_permissions')
|
||||
->whereIn('permissionKey', $permissionKeys)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$rows = array_map(fn ($id) => ['roleId' => $roleId, 'permissionId' => $id], $ids);
|
||||
|
||||
if ($rows !== []) {
|
||||
$this->db->table('zp_role_permissions')->insert($rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Grant a single permission to a role (no-op if already granted). */
|
||||
public function grant(int $roleId, string $permissionKey): void
|
||||
{
|
||||
$permission = $this->getPermissionByKey($permissionKey);
|
||||
if ($permission === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = $this->db->table('zp_role_permissions')
|
||||
->where('roleId', $roleId)
|
||||
->where('permissionId', $permission['id'])
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
$this->db->table('zp_role_permissions')->insert([
|
||||
'roleId' => $roleId,
|
||||
'permissionId' => $permission['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke a single permission from a role. */
|
||||
public function revoke(int $roleId, string $permissionKey): void
|
||||
{
|
||||
$permission = $this->getPermissionByKey($permissionKey);
|
||||
if ($permission === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->table('zp_role_permissions')
|
||||
->where('roleId', $roleId)
|
||||
->where('permissionId', $permission['id'])
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
77
app/Core/Auth/Permissions/PermissionSeeder.php
Normal file
77
app/Core/Auth/Permissions/PermissionSeeder.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
/**
|
||||
* Seeds the database-backed permission system from code declarations.
|
||||
*
|
||||
* Two idempotent operations:
|
||||
* - {@see syncDiscoveredPermissions()} writes the discovered `domain.action` vocabulary
|
||||
* into zp_permissions. Safe to run anytime; never touches role grants, so administrator
|
||||
* customizations survive a re-sync.
|
||||
* - {@see seedBuiltInRoles()} upserts the six built-in roles and ADDITIVELY grants each its
|
||||
* default permissions, resolved from the central {@see DefaultRolePermissions} matrix
|
||||
* against the discovered catalog. Additive grants never remove an administrator's edits.
|
||||
*
|
||||
* Vocabulary must be synced before grants can reference it, so the install migration calls
|
||||
* sync first, then seed.
|
||||
*/
|
||||
class PermissionSeeder
|
||||
{
|
||||
public function __construct(
|
||||
private PermissionRepository $repo,
|
||||
private PermissionRegistry $registry,
|
||||
private PermissionService $permissions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Upsert the discovered vocabulary into zp_permissions and bust the engine cache.
|
||||
*
|
||||
* @return array<int, string> The synced permission keys.
|
||||
*/
|
||||
public function syncDiscoveredPermissions(): array
|
||||
{
|
||||
$definitions = array_map(
|
||||
fn (Permission $p): array => [
|
||||
'key' => $p->key,
|
||||
'domain' => $p->domain(),
|
||||
'action' => $p->action(),
|
||||
'label' => $p->displayName,
|
||||
'projectScoped' => $p->projectScoped,
|
||||
],
|
||||
array_values($this->registry->all()),
|
||||
);
|
||||
|
||||
$keys = $this->repo->syncPermissions($definitions);
|
||||
$this->permissions->flushCache();
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the six built-in roles and additively grant their default permissions from the
|
||||
* central {@see DefaultRolePermissions} matrix.
|
||||
*/
|
||||
public function seedBuiltInRoles(): void
|
||||
{
|
||||
$catalog = array_values($this->registry->all());
|
||||
$roleIds = [];
|
||||
|
||||
foreach (DefaultRolePermissions::roles() as $role) {
|
||||
$roleIds[$role['name']] = $this->repo->upsertRole(
|
||||
$role['name'],
|
||||
$role['displayName'],
|
||||
$role['level'],
|
||||
isSystem: true,
|
||||
);
|
||||
}
|
||||
|
||||
foreach (DefaultRolePermissions::roles() as $role) {
|
||||
foreach (DefaultRolePermissions::grantsFor($role['name'], $catalog) as $permissionKey) {
|
||||
$this->repo->grant($roleIds[$role['name']], $permissionKey);
|
||||
}
|
||||
}
|
||||
|
||||
$this->permissions->flushCache();
|
||||
}
|
||||
}
|
||||
158
app/Core/Auth/Permissions/PermissionService.php
Normal file
158
app/Core/Auth/Permissions/PermissionService.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Auth\Contracts\ChecksProjectAccess;
|
||||
use Leantime\Core\Auth\RoleResolver;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
|
||||
/**
|
||||
* The capability engine: the single runtime answer to "may the current user do X?".
|
||||
*
|
||||
* Consumed everywhere through one method, {@see currentUserCan()}: the JSON-RPC
|
||||
* dispatcher and controller bases (via {@see RequiresPermission}), Blade `@can` (via the
|
||||
* Gate::before bridge), the menu builder, and in-method `$this->authorize()` helpers.
|
||||
*
|
||||
* Two concerns are kept strictly separate:
|
||||
* - CAPABILITY — does the user's effective role hold the permission? Resolved against the
|
||||
* cached role->permission grant map. Effective role is project-aware (see {@see RoleResolver}).
|
||||
* - DATA ACCESS — for project-scoped permissions targeting a concrete project, is the user
|
||||
* actually a member of (or otherwise able to access) that project? Admin/owner bypass.
|
||||
*
|
||||
* Ownership and other entity-specific checks deliberately live in callers, not here.
|
||||
*/
|
||||
class PermissionService
|
||||
{
|
||||
private const MAP_CACHE_KEY = 'leantime.permissionMap';
|
||||
|
||||
private const META_CACHE_KEY = 'leantime.permissionMeta';
|
||||
|
||||
public function __construct(
|
||||
private PermissionRepository $repo,
|
||||
private RoleResolver $roles,
|
||||
private ChecksProjectAccess $projectAccess,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Whether $roleName holds $permissionKey, by flat lookup on the cached grant map.
|
||||
*/
|
||||
public function roleHasPermission(string $roleName, string $permissionKey): bool
|
||||
{
|
||||
return in_array($permissionKey, $this->map()[$roleName] ?? [], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The authorization decision. Resolves the effective role (project-aware for
|
||||
* project-scoped permissions), checks the grant map, then — only for project-scoped
|
||||
* permissions against a concrete project — ANDs in project data access.
|
||||
*
|
||||
* @param string $permissionKey A `domain.action` key.
|
||||
* @param int|null $projectId The project the acted-on entity belongs to (for project-scoped checks).
|
||||
* @param bool|null $forceGlobal Force the global-role scope (company-wide screens). Null = inferred from the permission.
|
||||
*/
|
||||
public function currentUserCan(string $permissionKey, ?int $projectId = null, ?bool $forceGlobal = null): bool
|
||||
{
|
||||
$projectScoped = $this->isProjectScoped($permissionKey);
|
||||
$useGlobal = $forceGlobal === true || ! $projectScoped;
|
||||
|
||||
if ($useGlobal) {
|
||||
$role = $this->roles->effectiveRole(true);
|
||||
} elseif ($projectId !== null) {
|
||||
$role = $this->roles->effectiveRoleForProject($projectId);
|
||||
} else {
|
||||
$role = $this->roles->effectiveRole(false);
|
||||
}
|
||||
|
||||
if ($role === false || ! $this->roleHasPermission($role, $permissionKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Capability granted. Enforce project data access for project-scoped checks.
|
||||
if ($projectScoped && $projectId !== null && ! $this->canAccessAllProjects()) {
|
||||
return $this->projectAccess->isUserAssignedToProject((int) session('userdata.id'), $projectId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize or throw. Services should call this instead of returning false on denial,
|
||||
* so the failure maps cleanly to 403 (web) / RPC -32001.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function authorize(string $permissionKey, ?int $projectId = null, ?bool $forceGlobal = null): void
|
||||
{
|
||||
if (! $this->currentUserCan($permissionKey, $projectId, $forceGlobal)) {
|
||||
// Keep the permission key server-side only (audit/debug); the exception's
|
||||
// client-facing message stays generic so we don't expose authz vocabulary.
|
||||
Log::info('Authorization denied for permission "'.$permissionKey.'" (user '.(session('userdata.id') ?? 'guest').')');
|
||||
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $permissionKey is part of the synced vocabulary. Used by the Gate::before
|
||||
* bridge to defer (return null) on dotted abilities it does not own.
|
||||
*/
|
||||
public function isManagedPermission(string $permissionKey): bool
|
||||
{
|
||||
return isset($this->meta()[$permissionKey]);
|
||||
}
|
||||
|
||||
/** Whether a permission is evaluated per-project (true) or company-wide (false). */
|
||||
public function isProjectScoped(string $permissionKey): bool
|
||||
{
|
||||
return (bool) ($this->meta()[$permissionKey]['projectScoped'] ?? false);
|
||||
}
|
||||
|
||||
/** Forget the cached grant map + vocabulary meta. Call after any role/permission write. */
|
||||
public function flushCache(): void
|
||||
{
|
||||
Cache::store('installation')->forget(self::MAP_CACHE_KEY);
|
||||
Cache::store('installation')->forget(self::META_CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin/owner access every project (mirrors getProjectsUserHasAccessTo's bypass), so
|
||||
* they skip the per-project membership check.
|
||||
*/
|
||||
private function canAccessAllProjects(): bool
|
||||
{
|
||||
$globalRole = $this->roles->globalRole();
|
||||
|
||||
return $globalRole === Roles::$owner || $globalRole === Roles::$admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* The role -> [permissionKey, ...] grant map, cached on the shared installation store
|
||||
* (file/Redis) so all workers share it; busted via {@see flushCache()}.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private function map(): array
|
||||
{
|
||||
return Cache::store('installation')->rememberForever(self::MAP_CACHE_KEY, fn () => $this->repo->getRolePermissionMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* Vocabulary meta (key => ['projectScoped' => bool]), cached alongside the grant map.
|
||||
*
|
||||
* @return array<string, array{projectScoped: bool}>
|
||||
*/
|
||||
private function meta(): array
|
||||
{
|
||||
return Cache::store('installation')->rememberForever(self::META_CACHE_KEY, function () {
|
||||
$meta = [];
|
||||
foreach ($this->repo->getAllPermissions() as $permission) {
|
||||
$meta[$permission['permissionKey']] = ['projectScoped' => (bool) $permission['isProjectScoped']];
|
||||
}
|
||||
|
||||
return $meta;
|
||||
});
|
||||
}
|
||||
}
|
||||
93
app/Core/Auth/Permissions/PermissionServiceProvider.php
Normal file
93
app/Core/Auth/Permissions/PermissionServiceProvider.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
use Illuminate\Contracts\Auth\Access\Gate as GateContract;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Leantime\Core\Auth\Contracts\ChecksProjectAccess;
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* Wires the native permission engine: singletons, the project-access abstraction binding,
|
||||
* the Gate bridge, and dependency injection for {@see BaseService} subclasses.
|
||||
*
|
||||
* Registered in laravelConfig's provider list. Keeping this separate from
|
||||
* AuthenticationServiceProvider keeps authn (guards/tokens) and authz (permissions) cleanly
|
||||
* apart.
|
||||
*/
|
||||
class PermissionServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(PermissionService::class);
|
||||
$this->app->singleton(PermissionEnforcer::class);
|
||||
$this->app->singleton(PermissionRegistry::class);
|
||||
|
||||
// The engine depends on a narrow project-access abstraction, not the Projects
|
||||
// god-service. A shared singleton so RoleResolver and PermissionService reuse one
|
||||
// instance and we don't construct Projects more than once.
|
||||
$this->app->singleton(ChecksProjectAccess::class, fn ($app) => $app->make(Projects::class));
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerPermissionGate();
|
||||
$this->injectBaseServiceDependencies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge Laravel's authorization Gate to the engine. Leantime's authenticated user is a
|
||||
* stdClass (no `->can()`), so a single Gate::before hook resolves every `domain.action`
|
||||
* ability through {@see PermissionService::currentUserCan()} — making `@can('tickets.create')`,
|
||||
* `Gate::allows()`, and the `can` middleware all speak the one vocabulary. Non-permission
|
||||
* abilities (no dot, or not in the synced catalog) return null so other gates still work;
|
||||
* resolution is deferred into the closure so Projects isn't constructed at boot.
|
||||
*/
|
||||
protected function registerPermissionGate(): void
|
||||
{
|
||||
$container = $this->app;
|
||||
|
||||
$this->app->make(GateContract::class)->before(function ($user, string $ability, array $arguments = []) use ($container) {
|
||||
if (! str_contains($ability, '.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$permissions = $container->make(PermissionService::class);
|
||||
|
||||
if (! $permissions->isManagedPermission($ability)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only treat the first gate argument as a project id when it is numeric;
|
||||
// @can / Gate::allows may pass models or other objects. Otherwise fall back
|
||||
// to session scope (null).
|
||||
$projectId = isset($arguments[0]) && is_numeric($arguments[0]) ? (int) $arguments[0] : null;
|
||||
|
||||
return $permissions->currentUserCan($ability, $projectId);
|
||||
} catch (\Throwable) {
|
||||
// Permission tables not ready yet (e.g. pre-migration install) — defer.
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire PermissionService into every service that extends {@see BaseService}, without forcing
|
||||
* subclass constructors to wire it. The afterResolving callback fires for any resolved instance
|
||||
* that is `instanceof BaseService`.
|
||||
*
|
||||
* We wire a LAZY resolver, not the instance: a BaseService can sit inside PermissionService's
|
||||
* own dependency graph (Files is reached via PermissionService → ChecksProjectAccess → Projects
|
||||
* → Files), so eagerly calling `make(PermissionService)` here would re-enter PermissionService's
|
||||
* half-built construction and recurse infinitely (stack overflow at boot). Resolving lazily on
|
||||
* first authorize()/can() defers it until the singleton has been built.
|
||||
*/
|
||||
protected function injectBaseServiceDependencies(): void
|
||||
{
|
||||
$this->app->afterResolving(BaseService::class, function (BaseService $service, $app) {
|
||||
$service->setPermissionServiceResolver(fn () => $app->make(PermissionService::class));
|
||||
});
|
||||
}
|
||||
}
|
||||
33
app/Core/Auth/Permissions/ProvidesPermissions.php
Normal file
33
app/Core/Auth/Permissions/ProvidesPermissions.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
/**
|
||||
* Contract implemented by each domain's (and plugin's) permission catalog.
|
||||
*
|
||||
* Implementations live at `app/Domain/{Domain}/Permissions/{Domain}Permissions.php`
|
||||
* (and the plugin equivalent) and are auto-discovered at boot by
|
||||
* {@see PermissionRegistry}, mirroring how `register.php` event listeners are
|
||||
* discovered. The declared {@see Permission} objects are the single source of truth
|
||||
* for the `domain.action` vocabulary — `permissions:sync` writes them into the
|
||||
* `zp_permissions` table so an administrator can assign them to roles.
|
||||
*
|
||||
* Concrete implementations should also expose typed string constants
|
||||
* (e.g. `const CREATE = 'tickets.create';`) so call sites reference constants
|
||||
* rather than magic strings.
|
||||
*/
|
||||
interface ProvidesPermissions
|
||||
{
|
||||
/**
|
||||
* The capabilities this provider contributes to the vocabulary.
|
||||
*
|
||||
* @return array<int, Permission>
|
||||
*/
|
||||
public function permissions(): array;
|
||||
|
||||
/**
|
||||
* The domain key these permissions belong to (e.g. `tickets`). Used for grouping
|
||||
* in the admin UI and as the `zp_permissions.domain` column value.
|
||||
*/
|
||||
public function domain(): string;
|
||||
}
|
||||
51
app/Core/Auth/Permissions/RequiresPermission.php
Normal file
51
app/Core/Auth/Permissions/RequiresPermission.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Permissions;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* Declares the permission required to invoke a controller action or an `@api`
|
||||
* service method.
|
||||
*
|
||||
* The same declaration is enforced at every entry point to the service layer, all reading
|
||||
* it through {@see PermissionEnforcer}:
|
||||
* - Legacy convention routes: {@see \Leantime\Core\Controller\Frontcontroller::executeAction()}.
|
||||
* - Native Laravel routes: the {@see CheckPermissions} middleware.
|
||||
* - JSON-RPC: {@see \Leantime\Domain\Api\Controllers\Jsonrpc::executeApiRequest()} on the
|
||||
* resolved service method (RPC bypasses the controller gate, so this is what secures it).
|
||||
*
|
||||
* On denial an {@see \Leantime\Core\Exceptions\AuthorizationException} is thrown, which the
|
||||
* global handler renders as 403 on the web and `JsonRpcErrorResponse::fromException`
|
||||
* maps to RPC error -32001.
|
||||
*
|
||||
* How the project scope is resolved (mutually informative):
|
||||
* - `projectIdParam: 'projectId'` — the enforcer reads that request param and runs the
|
||||
* full project-scoped check. Use when the project id is a clean top-level argument.
|
||||
* - `global: true` — a company-wide capability (users/clients/settings); the enforcer
|
||||
* checks against the global role, not a project.
|
||||
* - `entityScoped: true` — the project comes from an entity the method loads itself
|
||||
* (e.g. `$ticket->projectId`), which the enforcer can't see beforehand. The attribute is
|
||||
* then a declared-coverage marker and the method body MUST call
|
||||
* `$this->authorize($perm, $entity->projectId)` to do the precise check.
|
||||
* - none of the above — falls back to the current session project (`session('currentProject')`),
|
||||
* appropriate for session-scoped views.
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_METHOD)]
|
||||
final class RequiresPermission
|
||||
{
|
||||
/**
|
||||
* @param string $permission The required `domain.action` key (use a domain
|
||||
* permission constant, e.g. `TicketsPermissions::CREATE`).
|
||||
* @param string|null $projectIdParam Name of the request param holding the project id.
|
||||
* @param bool $global Company-wide capability — check the global role, not a project.
|
||||
* @param bool $entityScoped Project is derived from an entity the method loads; the
|
||||
* enforcer defers and the method self-authorizes in its body.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $permission,
|
||||
public readonly ?string $projectIdParam = null,
|
||||
public readonly bool $global = false,
|
||||
public readonly bool $entityScoped = false,
|
||||
) {}
|
||||
}
|
||||
107
app/Core/Auth/RoleResolver.php
Normal file
107
app/Core/Auth/RoleResolver.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth;
|
||||
|
||||
use Leantime\Core\Auth\Contracts\ChecksProjectAccess;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
|
||||
/**
|
||||
* Single home for resolving a user's EFFECTIVE role, in both scopes Leantime cares about.
|
||||
*
|
||||
* Leantime roles are project-scoped: a user can hold one role globally
|
||||
* (`session('userdata.role')`) and a different role inside a given project
|
||||
* (`zp_relationuserproject.projectRole`). Authorization correctness depends on picking
|
||||
* the right one:
|
||||
* - {@see effectiveRole()} resolves the role for the CURRENT SESSION project — the
|
||||
* historical behavior of `Auth::getRoleToCheck()`, which this delegates to (no
|
||||
* duplication). Use it for "is the current screen allowed" checks.
|
||||
* - {@see effectiveRoleForProject()} resolves the role for a SPECIFIC project — the
|
||||
* only correct basis for authorizing a mutation on an entity that may live outside
|
||||
* the session project. It centralizes the logic previously private to
|
||||
* `Tickets::userIsAtLeastForProject()`.
|
||||
*
|
||||
* This is infrastructure shared across every domain, hence it lives in Core/Auth. It
|
||||
* still references the Domain-layer `Roles` definitions and `Projects` service for now;
|
||||
* those are stable value/lookup surfaces and the coupling is intentional pragmatism
|
||||
* (the broader Roles->Core move is deferred).
|
||||
*/
|
||||
class RoleResolver
|
||||
{
|
||||
public function __construct(private ChecksProjectAccess $projectAccess) {}
|
||||
|
||||
/** The current user's global role string, or null when not authenticated. */
|
||||
public function globalRole(): ?string
|
||||
{
|
||||
$role = session('userdata.role');
|
||||
|
||||
return ($role === null || $role === '') ? null : $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective role for the current session project (delegates to the existing
|
||||
* dual-scope resolution). `$forceGlobal` short-circuits to the global role for
|
||||
* company-wide screens (users/clients/settings).
|
||||
*/
|
||||
public function effectiveRole(bool $forceGlobal = false): string|false
|
||||
{
|
||||
return AuthService::getRoleToCheck($forceGlobal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective role for a specific project. Manager/admin/owner keep their global role
|
||||
* everywhere; otherwise the explicit project role applies, falling back to the
|
||||
* global role when none is set. Returns false when not authenticated.
|
||||
*/
|
||||
public function effectiveRoleForProject(int $projectId): string|false
|
||||
{
|
||||
$globalRole = $this->globalRole();
|
||||
|
||||
if ($globalRole === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = Roles::getRoles();
|
||||
$globalKey = array_search($globalRole, $roles, true);
|
||||
$managerKey = array_search(Roles::$manager, $roles, true);
|
||||
|
||||
// Manager and above keep their global role across every project.
|
||||
if ($globalKey !== false && $managerKey !== false && $globalKey >= $managerKey) {
|
||||
return $globalRole;
|
||||
}
|
||||
|
||||
$projectRole = $this->projectAccess->getProjectRole((int) session('userdata.id'), $projectId);
|
||||
|
||||
// No explicit project role -> inherit the global role.
|
||||
if ($projectRole === '') {
|
||||
return $globalRole;
|
||||
}
|
||||
|
||||
// getProjectRole() returns either a numeric role key or, for legacy rows, a role name.
|
||||
// Resolve a numeric key to its role string; accept an already-valid role name as-is. Anything
|
||||
// that still can't be resolved falls back to the global role rather than denying a genuine
|
||||
// member (false -> 403 is worse than granting their inherited global role) (#3618).
|
||||
$resolvedRole = ctype_digit((string) $projectRole)
|
||||
? Roles::getRoleString((int) $projectRole)
|
||||
: (in_array($projectRole, $roles, true) ? $projectRole : false);
|
||||
|
||||
return $resolvedRole === false ? $globalRole : $resolvedRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when $effectiveRole ranks at or above $requiredRole in the role hierarchy,
|
||||
* using the same ordering as {@see Roles::getRoles()}.
|
||||
*/
|
||||
public function atLeast(string $requiredRole, string|false $effectiveRole): bool
|
||||
{
|
||||
if ($effectiveRole === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = Roles::getRoles();
|
||||
$requiredKey = array_search($requiredRole, $roles, true);
|
||||
$effectiveKey = array_search($effectiveRole, $roles, true);
|
||||
|
||||
return $requiredKey !== false && $effectiveKey !== false && $effectiveKey >= $requiredKey;
|
||||
}
|
||||
}
|
||||
25
app/Core/Auth/Tokens/SanctumServiceProvider.php
Normal file
25
app/Core/Auth/Tokens/SanctumServiceProvider.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Auth\Tokens;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Sanctum\Contracts\HasAbilities;
|
||||
use Laravel\Sanctum\Sanctum as SanctumBase;
|
||||
use Leantime\Domain\Auth\Services\AccessToken;
|
||||
|
||||
class SanctumServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(HasAbilities::class, AccessToken::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
|
||||
// Use our custom token model
|
||||
// @phpstan-ignore-next-line argument.type
|
||||
SanctumBase::usePersonalAccessTokenModel(AccessToken::class);
|
||||
|
||||
}
|
||||
}
|
||||
99
app/Core/Bootloader.php
Normal file
99
app/Core/Bootloader.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Console\ConsoleKernel;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\HttpKernel;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
|
||||
/**
|
||||
* Bootloader
|
||||
*/
|
||||
class Bootloader
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* Bootloader instance
|
||||
*/
|
||||
protected static ?Bootloader $instance = null;
|
||||
|
||||
protected Application $app;
|
||||
|
||||
/**
|
||||
* Get the Bootloader instance
|
||||
*/
|
||||
public static function getInstance(): self
|
||||
{
|
||||
|
||||
if (is_null(static::$instance)) {
|
||||
static::$instance = new self;
|
||||
}
|
||||
|
||||
return static::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Execute the Application lifecycle.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function boot(Application $app)
|
||||
{
|
||||
// Start Application
|
||||
// Load the bindings and service providers
|
||||
$this->app = $app;
|
||||
|
||||
// Capture the request and instantiate the correct type
|
||||
$request = IncomingRequest::capture();
|
||||
|
||||
// Use the right kernel for the job and handle the request.
|
||||
$this->handleRequest($request);
|
||||
|
||||
self::dispatchEvent('end', ['bootloader' => $this]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the request
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
private function handleRequest($request): void
|
||||
{
|
||||
|
||||
if (! $this->app->runningInConsole()) {
|
||||
|
||||
/** @var HttpKernel $kernel */
|
||||
$kernel = $this->app->make(HttpKernel::class);
|
||||
|
||||
$kernelHandler = $kernel->handle($request);
|
||||
$response = $kernelHandler->send();
|
||||
|
||||
$kernel->terminate($request, $response);
|
||||
|
||||
} else {
|
||||
|
||||
/** @var ConsoleKernel $kernel */
|
||||
$kernel = $this->app->make(ConsoleKernel::class);
|
||||
|
||||
$status = $kernel->handle(
|
||||
$input = new \Symfony\Component\Console\Input\ArgvInput,
|
||||
new \Symfony\Component\Console\Output\ConsoleOutput
|
||||
);
|
||||
|
||||
$kernel->terminate($input, $status);
|
||||
|
||||
exit($status);
|
||||
}
|
||||
}
|
||||
}
|
||||
201
app/Core/Bootstrap/LoadConfig.php
Normal file
201
app/Core/Bootstrap/LoadConfig.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Bootstrap;
|
||||
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Contracts\Config\Repository as RepositoryContract;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Foundation\Bootstrap\LoadConfiguration;
|
||||
use Illuminate\Http\Request;
|
||||
use Leantime\Core\Configuration\Attributes\LaravelConfig;
|
||||
use Leantime\Core\Configuration\DefaultConfig;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
|
||||
class LoadConfig extends LoadConfiguration
|
||||
{
|
||||
protected $ignoreFiles = [
|
||||
'configuration.sample.php',
|
||||
'configuration.php',
|
||||
];
|
||||
|
||||
protected $headers = IncomingRequest::HEADER_X_FORWARDED_FOR |
|
||||
IncomingRequest::HEADER_X_FORWARDED_HOST |
|
||||
IncomingRequest::HEADER_X_FORWARDED_PORT |
|
||||
IncomingRequest::HEADER_X_FORWARDED_PROTO |
|
||||
IncomingRequest::HEADER_X_FORWARDED_AWS_ELB;
|
||||
|
||||
/**
|
||||
* Bootstrap the application.
|
||||
*
|
||||
* This method initializes the application by loading the configuration files and
|
||||
* setting up the environment.
|
||||
*
|
||||
* @param Application $app The application instance.
|
||||
* @return void
|
||||
*/
|
||||
public function bootstrap(Application $app)
|
||||
{
|
||||
$items = [];
|
||||
|
||||
// First we will see if we have a cache configuration file. If we do, we'll load
|
||||
// the configuration items from that file so that it is very quick. Otherwise
|
||||
// we will need to spin through every configuration file and load them all.
|
||||
if (file_exists($cached = $app->getCachedConfigPath())) {
|
||||
$items = require $cached;
|
||||
|
||||
$loadedFromCache = true;
|
||||
}
|
||||
|
||||
// Next we will spin through all of the configuration files in the configuration
|
||||
// directory and load each one into the repository. This will make all of the
|
||||
// options available to the developer for use in various parts of this app.
|
||||
$app->instance('config', $config = new Environment($items));
|
||||
|
||||
if (! isset($loadedFromCache)) {
|
||||
$this->loadConfigurationFiles($app, $config);
|
||||
|
||||
// Now extend config with laravel configs if they exist
|
||||
$app->extend('config', function (Repository $config) use ($app) {
|
||||
|
||||
// $leantimeConfig = $app->make(Environment::class);
|
||||
|
||||
// Add all laravel configs to leantime config
|
||||
// foreach ($laravelConfig->all() as $key => $value) {
|
||||
// $leantimeConfig->set($key, $value);
|
||||
// }
|
||||
|
||||
// At this point we have the leantime config and loaded laravel configs
|
||||
// Re-aranging and setting some of the laravel defaults that were not set
|
||||
// as part of the file loader. Laravel config vars were already added.
|
||||
$finalConfig = $this->mapLeantime2LaravelConfig($config);
|
||||
|
||||
// Additional adjustments
|
||||
$finalConfig->set('APP_DEBUG', $finalConfig->get('debug') ? true : false);
|
||||
|
||||
if (preg_match('/.+\/$/', $finalConfig->get('appUrl'))) {
|
||||
$url = rtrim($finalConfig->get('appUrl'), '/');
|
||||
$finalConfig->set('appUrl', $url);
|
||||
$finalConfig->set('app.url', $url);
|
||||
}
|
||||
|
||||
$this->setBaseConstants($finalConfig, $app);
|
||||
|
||||
if ($finalConfig->get('app.url') == '') {
|
||||
$url = defined('BASE_URL') ? BASE_URL : 'http://localhost';
|
||||
$finalConfig->set('app.url', $url);
|
||||
}
|
||||
|
||||
// Handle trailing slashes
|
||||
return $finalConfig;
|
||||
});
|
||||
}
|
||||
|
||||
// Need to run this in case config is coming from cache
|
||||
$this->setBaseConstants($app['config'], $app);
|
||||
|
||||
$config = $app['config'];
|
||||
|
||||
$app['events']->dispatch('config_initialized');
|
||||
|
||||
// Finally, we will set the application's environment based on the configuration
|
||||
// values that were loaded. We will pass a callback which will be used to get
|
||||
// the environment in a web context where an "--env" switch is not present.
|
||||
$app->detectEnvironment(fn () => $config->get('app.env', 'production'));
|
||||
|
||||
date_default_timezone_set($config->get('app.timezone', 'UTC'));
|
||||
|
||||
mb_internal_encoding('UTF-8');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the URL constants for the application.
|
||||
*
|
||||
* If the BASE_URL constant is not defined, it will be set based on the value of $appUrl parameter.
|
||||
* If $appUrl is empty or not provided, it will be set using the getSchemeAndHttpHost method of the class.
|
||||
*
|
||||
* The APP_URL environment variable will be set to the value of $appUrl.
|
||||
*
|
||||
* If the CURRENT_URL constant is not defined, it will be set by appending the getRequestUri method result to the BASE_URL.
|
||||
*
|
||||
* @param mixed $config The configuration object providing the appUrl value.
|
||||
* @param mixed $app The application instance used to resolve the request.
|
||||
* @return void
|
||||
*/
|
||||
public function setBaseConstants($config, $app)
|
||||
{
|
||||
|
||||
$appUrl = $config->get('appUrl');
|
||||
|
||||
// Set trusted prozies as early as possible to ensure schema is identified correctly
|
||||
$proxies = explode(',', ($config->trustedProxies ?? '127.0.0.1,REMOTE_ADDR'));
|
||||
Request::setTrustedProxies($proxies, $this->headers);
|
||||
|
||||
if (! defined('BASE_URL')) {
|
||||
if (isset($appUrl) && ! empty($appUrl)) {
|
||||
define('BASE_URL', $appUrl);
|
||||
} else {
|
||||
$appUrl = ! empty($app['request']) ? $app['request']->getSchemeAndHttpHost() : 'http://localhost';
|
||||
define('BASE_URL', $appUrl);
|
||||
}
|
||||
}
|
||||
|
||||
putenv('APP_URL='.$appUrl);
|
||||
|
||||
if (! defined('CURRENT_URL')) {
|
||||
define('CURRENT_URL', ! empty($app['request']) ? BASE_URL.$app['request']->getRequestUri() : 'http://localhost');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the configuration files.
|
||||
*
|
||||
* This method loads the Laravel configuration files and sets them into the given repository.
|
||||
*
|
||||
* @param Application $app The application instance.
|
||||
* @param RepositoryContract $repository The repository where the configuration files will be set.
|
||||
* @return void
|
||||
*/
|
||||
protected function loadConfigurationFiles(Application $app, RepositoryContract $repository)
|
||||
{
|
||||
$laravelConfig = require APP_ROOT.'/app/Core/Configuration/laravelConfig.php';
|
||||
foreach ($laravelConfig as $key => $configArea) {
|
||||
$repository->set($key, $configArea);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Leantime configuration options to Laravel configuration options.
|
||||
*
|
||||
* @param mixed $config The Laravel configuration object to map to.
|
||||
* @return mixed The updated Leantime configuration object with mapped values.
|
||||
*/
|
||||
protected function mapLeantime2LaravelConfig($config)
|
||||
{
|
||||
|
||||
$reflectionClass = new \ReflectionClass(DefaultConfig::class);
|
||||
$properties = $reflectionClass->getProperties();
|
||||
|
||||
// Parsing through all the leantime config options.
|
||||
// Default tracks a mapping via attributes
|
||||
foreach ($properties as $configVar) {
|
||||
$attributes = $configVar->getAttributes(LaravelConfig::class);
|
||||
|
||||
if (isset($attributes[0])) {
|
||||
|
||||
$laravelConfigKey = $attributes[0]->newInstance()->config;
|
||||
$defaultConfigkey = $configVar->name;
|
||||
|
||||
// set laravel config.
|
||||
// Leantime env file has priority and can override previously defined laravel configs
|
||||
$config->set($laravelConfigKey, $config->get($defaultConfigkey));
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
public function configValidation() {}
|
||||
}
|
||||
42
app/Core/Bootstrap/SetRequestForConsole.php
Normal file
42
app/Core/Bootstrap/SetRequestForConsole.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Bootstrap;
|
||||
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Leantime\Core\Console\CliRequest;
|
||||
|
||||
class SetRequestForConsole
|
||||
{
|
||||
/**
|
||||
* Bootstrap the given application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function bootstrap(Application $app)
|
||||
{
|
||||
$uri = $app->make('config')->get('app.url', 'http://localhost');
|
||||
$uri = empty($uri) ? 'http://localhost' : $uri;
|
||||
|
||||
$components = parse_url($uri);
|
||||
|
||||
$server = $_SERVER;
|
||||
|
||||
if (isset($components['path'])) {
|
||||
$server = array_merge([
|
||||
'SCRIPT_FILENAME' => $components['path'],
|
||||
'SCRIPT_NAME' => $components['path'],
|
||||
], $server);
|
||||
}
|
||||
|
||||
if (! defined('BASE_URL')) {
|
||||
define('BASE_URL', $uri);
|
||||
}
|
||||
|
||||
// IMPORTANT: We can't use the native laravel bootstrapper for this because they inject Illuminate\Http\Request
|
||||
// And we need CliRequest
|
||||
$app->instance('request', CliRequest::create(
|
||||
$uri, 'GET', [], [], [], $server
|
||||
));
|
||||
|
||||
}
|
||||
}
|
||||
85
app/Core/Cache/CacheServiceProvider.php
Normal file
85
app/Core/Cache/CacheServiceProvider.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Cache;
|
||||
|
||||
use Illuminate\Cache\CacheServiceProvider as LaravelCacheServiceProvider;
|
||||
use Illuminate\Cache\MemcachedConnector;
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Symfony\Component\Cache\Adapter\Psr16Adapter;
|
||||
|
||||
class CacheServiceProvider extends LaravelCacheServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
|
||||
$this->app->singleton('cache', function ($app) {
|
||||
|
||||
// Now that we know where the instance is bing called from
|
||||
// Let's add a domain level cache.
|
||||
$domainCacheName = get_domain_key();
|
||||
|
||||
$app['config']->set('cache.stores.'.$domainCacheName, [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/'.$domainCacheName.'/data'),
|
||||
]);
|
||||
|
||||
// If redis is set up let's use redis as cache
|
||||
if ($app['config']['useRedis']) {
|
||||
|
||||
$app['config']->set('cache.prefix', '');
|
||||
|
||||
// Default driver just in case it is being asked for
|
||||
$app['config']->set('cache.stores.redis.driver', 'redis');
|
||||
$app['config']->set('cache.stores.redis.connection', 'cache');
|
||||
|
||||
// Only needed when using sessions with redis
|
||||
$app['config']->set('cache.stores.sessions.driver', 'redis');
|
||||
$app['config']->set('cache.stores.sessions.connection', 'sessions');
|
||||
|
||||
$app['config']->set('cache.stores.installation.driver', 'redis');
|
||||
$app['config']->set('cache.stores.installation.connection', 'installation');
|
||||
|
||||
$app['config']->set('cache.stores.'.$domainCacheName.'.driver', 'redis');
|
||||
$app['config']->set('cache.stores.'.$domainCacheName.'.connection', 'cache');
|
||||
$app['config']->set('cache.stores.'.$domainCacheName.'.prefix', ''.$domainCacheName.':');
|
||||
|
||||
}
|
||||
|
||||
$cacheManager = new \Illuminate\Cache\CacheManager($app);
|
||||
$cacheManager->setDefaultDriver($domainCacheName);
|
||||
|
||||
return $cacheManager;
|
||||
});
|
||||
|
||||
$this->app->singleton('cache.store', function ($app) {
|
||||
return $app['cache']->driver();
|
||||
});
|
||||
|
||||
$this->app->singleton('cache.psr6', function ($app) {
|
||||
return new Psr16Adapter($app['cache.store']);
|
||||
});
|
||||
|
||||
$this->app->singleton('memcached.connector', function () {
|
||||
return new MemcachedConnector;
|
||||
});
|
||||
|
||||
$this->app->singleton(RateLimiter::class, function ($app) {
|
||||
return new RateLimiter($app->make('cache')->driver(
|
||||
$app['config']->get('cache.limiter')
|
||||
));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public function provides()
|
||||
{
|
||||
return [
|
||||
'cache', 'cache.store', 'cache.psr6', RateLimiter::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
95
app/Core/Cache/Redis/RedisServiceProvider.php
Normal file
95
app/Core/Cache/Redis/RedisServiceProvider.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Cache\Redis;
|
||||
|
||||
use Illuminate\Redis\RedisManager;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class RedisServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
// Ensure cluster configuration is properly set before we configure stores
|
||||
if ($this->app['config']->useCluster) {
|
||||
$this->app['config']->set('redis.client', 'phpredis');
|
||||
$this->app['config']->set('redis.cluster', 'redis');
|
||||
}
|
||||
|
||||
$this->app->singleton('redis', function ($app) {
|
||||
// Setting up three different redis stores
|
||||
// We are using a slightly different config structure and keep redis at the root level of our config
|
||||
|
||||
$cacheConfig = $app['config']['redis']['default'];
|
||||
$cacheConfig['prefix'] = 'leantime_cache:';
|
||||
|
||||
$installationConfig = $app['config']['redis']['default'];
|
||||
$installationConfig['prefix'] = 'leantime_cache:installation:';
|
||||
|
||||
$sessionsConfig = $app['config']['redis']['default'];
|
||||
$sessionsConfig['prefix'] = 'leantime_sessions:';
|
||||
|
||||
// Prepare available redis connections
|
||||
// These connections (cache, installation, sessions) can be used for sessions and cache
|
||||
if ($app['config']->useCluster) {
|
||||
// Cluster configs and prefix management works differently than regular connections
|
||||
$app['config']->set('redis.clusters.default', [$app['config']['redis']['default']]);
|
||||
$options = $app['config']['redis']['options'];
|
||||
|
||||
// The default config is not needed anymore and shouldn't be used since the connection is a cluster
|
||||
// connection and won't work in the standard config setup
|
||||
$app['config']->set('redis.default', null);
|
||||
$app['config']->set('redis.cluster', true);
|
||||
|
||||
$app['config']->set('redis.clusters.cache', array_merge(['options' => $options], [$cacheConfig]));
|
||||
$app['config']->set('redis.clusters.cache.options.prefix', $cacheConfig['prefix']);
|
||||
|
||||
$app['config']->set('redis.clusters.installation', array_merge(['options' => $options], [$installationConfig]));
|
||||
$app['config']->set('redis.clusters.installation.options.prefix', $installationConfig['prefix']);
|
||||
|
||||
$app['config']->set('redis.clusters.sessions', array_merge(['options' => $options], [$sessionsConfig]));
|
||||
$app['config']->set('redis.clusters.sessions.options.prefix', $sessionsConfig['prefix']);
|
||||
|
||||
// Set cluster specific options
|
||||
$app['config']->set('redis.options', [
|
||||
'cluster' => 'redis',
|
||||
'parameters' => ['timeout' => 1.0],
|
||||
]);
|
||||
} else {
|
||||
// Sessions live in their OWN redis database. Cache and sessions previously
|
||||
// shared db 0 (differing only by key prefix), so ANY cache flush — Laravel's
|
||||
// RedisStore::flush() issues FLUSHDB — destroyed every session and logged every
|
||||
// user out (e.g. whenever `cache:clear` ran against production). Redis Cluster
|
||||
// does not support SELECT, so isolation only applies to non-cluster setups.
|
||||
$cacheDb = (int) ($cacheConfig['database'] ?? 0);
|
||||
$sessionsConfig['database'] = (int) env('LEAN_REDIS_SESSION_DB', $cacheDb === 0 ? 1 : 0);
|
||||
|
||||
$app['config']->set('redis.cache', $cacheConfig);
|
||||
$app['config']->set('redis.installation', $installationConfig);
|
||||
$app['config']->set('redis.sessions', $sessionsConfig);
|
||||
}
|
||||
|
||||
$redisManager = new RedisManager($app, 'phpredis', $app['config']['redis']);
|
||||
|
||||
return $redisManager;
|
||||
});
|
||||
|
||||
$this->app->bind('redis.connection', function ($app) {
|
||||
return $app['redis']->connection();
|
||||
});
|
||||
}
|
||||
|
||||
public function provides()
|
||||
{
|
||||
return ['redis', 'redis.connection'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the instance cache.
|
||||
*/
|
||||
public function checkCacheVersion(): void {}
|
||||
}
|
||||
13
app/Core/Configuration/AppSettings.php
Normal file
13
app/Core/Configuration/AppSettings.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Configuration;
|
||||
|
||||
/**
|
||||
* appSettings class - System appSettings
|
||||
*/
|
||||
class AppSettings
|
||||
{
|
||||
public string $appVersion = '3.9.8';
|
||||
|
||||
public string $dbVersion = '3.5.29';
|
||||
}
|
||||
15
app/Core/Configuration/Attributes/LaravelConfig.php
Normal file
15
app/Core/Configuration/Attributes/LaravelConfig.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Configuration\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute]
|
||||
class LaravelConfig
|
||||
{
|
||||
public function __construct(
|
||||
public string $config,
|
||||
) {
|
||||
//
|
||||
}
|
||||
}
|
||||
583
app/Core/Configuration/DefaultConfig.php
Normal file
583
app/Core/Configuration/DefaultConfig.php
Normal file
@@ -0,0 +1,583 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Configuration;
|
||||
|
||||
use Leantime\Core\Configuration\Attributes\LaravelConfig;
|
||||
|
||||
/**
|
||||
* Default Configuration Class
|
||||
*/
|
||||
class DefaultConfig
|
||||
{
|
||||
// General =====================================================================================
|
||||
/**
|
||||
* @var string Name of your site, can be changed later
|
||||
*/
|
||||
#[LaravelConfig('app.name')]
|
||||
public string $sitename = 'OneBot';
|
||||
|
||||
/**
|
||||
* @var string Default language
|
||||
*/
|
||||
#[LaravelConfig('app.locale')]
|
||||
public string $language = 'en-US';
|
||||
|
||||
/**
|
||||
* @var string Default logo path, can be changed later
|
||||
*/
|
||||
public string $logoPath = '/dist/images/logo.svg';
|
||||
|
||||
/**
|
||||
* @var string Default logo URL use for printing (must be jpg or png format)
|
||||
*/
|
||||
public string $printLogoURL = '/dist/images/logo.jpg';
|
||||
|
||||
/**
|
||||
* @var string Base URL, trailing slash not needed
|
||||
*/
|
||||
#[LaravelConfig('app.url')]
|
||||
public string $appUrl = '';
|
||||
|
||||
/**
|
||||
* @var string Base of application withotu trailing slash (used for cookies), e.g, /leantime
|
||||
*/
|
||||
public string $appDir = '';
|
||||
|
||||
/**
|
||||
* @var bool Send anonymous data <a href='https://docs.leantime.io/#/using-leantime/company-settings?id=telemetry' target='_blank'>More Info</a>.
|
||||
* No personally identifiable data will be sent and it will be impossible for us to track individual users.
|
||||
*/
|
||||
public bool $allowTelemetry = true;
|
||||
|
||||
/**
|
||||
* @var string Default theme
|
||||
*/
|
||||
public string $defaultTheme = 'default';
|
||||
|
||||
/**
|
||||
* @var string Primary Theme color
|
||||
*/
|
||||
public string $primarycolor;
|
||||
|
||||
/**
|
||||
* @var string Secondary Theme Color
|
||||
*/
|
||||
public string $secondarycolor;
|
||||
|
||||
/**
|
||||
* @var string Default timezone
|
||||
*/
|
||||
#[LaravelConfig('app.timezone')]
|
||||
public string $defaultTimezone = 'America/Los_Angeles';
|
||||
|
||||
/**
|
||||
* @var bool Enable to specifiy menu on a project by project basis
|
||||
*/
|
||||
public bool $enableMenuType = false;
|
||||
|
||||
/**
|
||||
* @var bool|int Debug flag
|
||||
*/
|
||||
#[LaravelConfig('app.debug')]
|
||||
public int|bool $debug = 0;
|
||||
|
||||
/**
|
||||
* @var bool When true, a denied #[RequiresPermission] attribute blocks the request
|
||||
* (403 web / JSON-RPC -32001). When false the central enforcer only LOGS the
|
||||
* would-be denial (audit mode), so permission coverage can be rolled out and
|
||||
* observed before being enforced. In-method $this->authorize() calls always
|
||||
* enforce regardless of this flag.
|
||||
*/
|
||||
#[LaravelConfig('permissions.enforce')]
|
||||
public bool $permissionsEnforce = true;
|
||||
|
||||
/**
|
||||
* @var string editor used for code editing
|
||||
*/
|
||||
public string $editor = 'phpstorm';
|
||||
|
||||
/**
|
||||
* @var string Application environment
|
||||
*/
|
||||
#[LaravelConfig('app.env')]
|
||||
public string $env = 'production';
|
||||
|
||||
/**
|
||||
* @var string Log Path
|
||||
*/
|
||||
public string $logPath = APP_ROOT.'/storage/logs/error.log';
|
||||
|
||||
/**
|
||||
* @var bool Whether or not to enable the Poor Man's Cron fallback
|
||||
*/
|
||||
public bool $poorMansCron = true;
|
||||
|
||||
/**
|
||||
* @var bool Don't show user/pass form on login?
|
||||
*/
|
||||
public bool $disableLoginForm = false;
|
||||
|
||||
// Database ====================================================================================
|
||||
/**
|
||||
* @var string Database host
|
||||
*/
|
||||
public string $dbHost = 'localhost';
|
||||
|
||||
/**
|
||||
* @var string Database username
|
||||
*/
|
||||
public string $dbUser = '';
|
||||
|
||||
/**
|
||||
* @var string Database password
|
||||
*/
|
||||
public string $dbPassword = '';
|
||||
|
||||
/**
|
||||
* @var string Database name
|
||||
*/
|
||||
public string $dbDatabase = '';
|
||||
|
||||
/**
|
||||
* @var string Database port
|
||||
*/
|
||||
public string $dbPort = '3306';
|
||||
|
||||
// Fileupload ==================================================================================
|
||||
/**
|
||||
* @var string Local relative path to store uploaded files (if not using S3)
|
||||
*/
|
||||
public string $userFilePath = 'userfiles/';
|
||||
|
||||
/**
|
||||
* @var string Local relative path to store backup files, need permission to write
|
||||
*/
|
||||
public string $dbBackupPath = 'userfiles/';
|
||||
|
||||
// S3 configuration ============================================================================
|
||||
/**
|
||||
* @var bool Set to true if you want to use S3 instead of local files
|
||||
*/
|
||||
public bool $useS3 = false;
|
||||
|
||||
/**
|
||||
* @var string S3 Key
|
||||
*/
|
||||
public string $s3Key = '';
|
||||
|
||||
/**
|
||||
* @var string S3 Secret
|
||||
*/
|
||||
public string $s3Secret = '';
|
||||
|
||||
/**
|
||||
* @var string S3 Bucket
|
||||
*/
|
||||
public string $s3Bucket = '';
|
||||
|
||||
/**
|
||||
* @var bool false => https://[bucket].[endpoint] ; true => https://[endpoint]/[bucket]
|
||||
*/
|
||||
public bool $s3UsePathStyleEndpoint = false;
|
||||
|
||||
/**
|
||||
* @var string S3 Region
|
||||
*/
|
||||
public string $s3Region = '';
|
||||
|
||||
/**
|
||||
* @var string S3 Foldername within S3 (can be empty)
|
||||
*/
|
||||
public string $s3FolderName = '';
|
||||
|
||||
/**
|
||||
* @var string|null S3 EndPoint S3 Compatible
|
||||
*
|
||||
* @see https://sfo2.digitaloceanspaces.com
|
||||
*/
|
||||
public ?string $s3EndPoint = null;
|
||||
|
||||
// Sessions ====================================================================================
|
||||
/**
|
||||
* @var string Salting sessions. Replace with a strong password
|
||||
*/
|
||||
#[LaravelConfig('app.key')]
|
||||
public string $sessionPassword = '3evBlq9zdUEuzKvVJHWWx3QzsQhturBApxwcws2m';
|
||||
|
||||
/**
|
||||
* @var int How many minutes after inactivity should we logout? 480min = 8hours
|
||||
*/
|
||||
public int $sessionExpiration = 480;
|
||||
|
||||
/**
|
||||
* @var bool. Sets whether the cookie should only be served via https
|
||||
*/
|
||||
#[LaravelConfig('session.secure')]
|
||||
public bool $sessionSecure = false;
|
||||
|
||||
// Email =======================================================================================
|
||||
/**
|
||||
* @var string Return email address
|
||||
*/
|
||||
public string $email = '';
|
||||
|
||||
/**
|
||||
* @var bool Use SMTP? If set to false, the default php mail() function will be used
|
||||
*/
|
||||
public bool $useSMTP = false;
|
||||
|
||||
/**
|
||||
* @var string SMTP host
|
||||
*/
|
||||
public string $smtpHosts = '';
|
||||
|
||||
/**
|
||||
* @var bool SMTP use user/password authentication
|
||||
*/
|
||||
public bool $smtpAuth = true;
|
||||
|
||||
/**
|
||||
* @var string SMTP username
|
||||
*/
|
||||
public string $smtpUsername = '';
|
||||
|
||||
/**
|
||||
* @var string SMTP password
|
||||
*/
|
||||
public string $smtpPassword = '';
|
||||
|
||||
/**
|
||||
* @var bool SMTP Enable TLS encryption automatically if a server supports it
|
||||
*/
|
||||
public bool $smtpAutoTLS = true;
|
||||
|
||||
/**
|
||||
* @var string SMTP Security protocol (usually one of: TLS, SSL, STARTTLS)
|
||||
*/
|
||||
public string $smtpSecure = '';
|
||||
|
||||
/**
|
||||
* @var bool SMTP Allow insecure SSL: Don't verify certificate, accept self-signed, etc.
|
||||
*/
|
||||
public bool $smtpSSLNoverify = false;
|
||||
|
||||
/**
|
||||
* @var int SMTP Port (usually one of 25, 465, 587, 2526)
|
||||
*/
|
||||
public int $smtpPort = 587;
|
||||
|
||||
// ldap default settings (can be changed in company settings) ==================================
|
||||
/**
|
||||
* @var bool Set to true if you want to use LDAP
|
||||
*/
|
||||
public bool $useLdap = false;
|
||||
|
||||
/**
|
||||
* @var string Select the correct directory type. Currently Supported: OL - OpenLdap, AD - Active Directory
|
||||
*/
|
||||
public string $ldapType = 'OL';
|
||||
|
||||
/**
|
||||
* @var string LDAP host (FQDN)
|
||||
*/
|
||||
public string $ldapHost = '';
|
||||
|
||||
/**
|
||||
* @var int LDAP port
|
||||
*/
|
||||
public int $ldapPort = 389;
|
||||
|
||||
/**
|
||||
* @var string LDAP domain
|
||||
*/
|
||||
public string $ldapDomain = '';
|
||||
|
||||
/**
|
||||
* @var string LDAP base DN
|
||||
*/
|
||||
public string $ldapUri = '';
|
||||
|
||||
/**
|
||||
* @var string Location of users, example: CN=users,DC=example,DC=com
|
||||
*/
|
||||
public string $ldapDn = '';
|
||||
|
||||
/**
|
||||
* @var string Default LDAP keys in your directory. Works for OL
|
||||
*/
|
||||
public string $ldapKeys = '{
|
||||
"username":"uid",
|
||||
"groups":"memberof",
|
||||
"email":"mail",
|
||||
"firstname":"displayname",
|
||||
"lastname":"",
|
||||
"phone":"",
|
||||
"jobTitle":"title",
|
||||
"jobLevel":"level",
|
||||
"department":"department"
|
||||
}';
|
||||
// For AD use
|
||||
/*
|
||||
public $ldapKeys = '{
|
||||
"username":"cn",
|
||||
"groups":"memberof",
|
||||
"email":"mail",
|
||||
"firstname":"givenname",
|
||||
"lastname":"sn",
|
||||
"phone":"telephoneNumber",
|
||||
"jobTitle":"title",
|
||||
"jobLevel":"level",
|
||||
"department":"department"
|
||||
}';
|
||||
*/
|
||||
|
||||
/**
|
||||
* @var bool Create users
|
||||
* Create user if not exists
|
||||
*/
|
||||
public bool $ldapCreateUser = false;
|
||||
|
||||
/**
|
||||
* @var string Default role assignments upon first login. (Optional) Can be updated in user settings for each user
|
||||
*/
|
||||
public string $ldapLtGroupAssignments = '{
|
||||
"5": {
|
||||
"ltRole":"readonly",
|
||||
"ldapRole":""
|
||||
},
|
||||
"10": {
|
||||
"ltRole":"commenter",
|
||||
"ldapRole":""
|
||||
},
|
||||
"20": {
|
||||
"ltRole":"editor",
|
||||
"ldapRole":""
|
||||
},
|
||||
"30": {
|
||||
"ltRole":"manager",
|
||||
"ldapRole":""
|
||||
},
|
||||
"40": {
|
||||
"ltRole":"admin",
|
||||
"ldapRole":""
|
||||
},
|
||||
"50": {
|
||||
"ltRole":"owner",
|
||||
"ldapRole":"administrators"
|
||||
}
|
||||
}';
|
||||
// Default Leantime Role on creation. (set to editor)
|
||||
|
||||
/**
|
||||
* @var int Default Leantime Role on creation. (set to editor)
|
||||
*/
|
||||
public int $ldapDefaultRoleKey = 20;
|
||||
|
||||
// Plugin Settings ==============================================================================
|
||||
/**
|
||||
* @var string Comma separated list of plugins that will always be loaded
|
||||
*/
|
||||
public string $plugins = '';
|
||||
|
||||
/**
|
||||
* @var string The Url of the Marketplace
|
||||
**/
|
||||
public string $marketplaceUrl = 'https://marketplace.leantime.io/';
|
||||
|
||||
// OIDC Settings ================================================================================
|
||||
/**
|
||||
* @var bool Set to true if you want to use OIDC
|
||||
*/
|
||||
public bool $oidcEnable = false;
|
||||
|
||||
/**
|
||||
* @var string OIDC Provider URL
|
||||
*/
|
||||
public string $oidcProviderUrl = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Client ID
|
||||
*/
|
||||
public string $oidcClientId = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Client Secret
|
||||
*/
|
||||
public string $oidcClientSecret = '';
|
||||
|
||||
/**
|
||||
* @var string Custom Auto discover URL
|
||||
*/
|
||||
public string $oidcAutoDiscoverUrl = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Auth URL
|
||||
*/
|
||||
public string $oidcAuthUrl = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Token URL
|
||||
*/
|
||||
public string $oidcTokenUrl = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC JWKS URL
|
||||
*/
|
||||
public string $oidcJwksUrl = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC User Info URL
|
||||
*/
|
||||
public string $oidcUserInfoUrl = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Certificate String
|
||||
*/
|
||||
public string $oidcCertificateString = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Certificate File
|
||||
*/
|
||||
public string $oidcCertificateFile = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Scopes
|
||||
*/
|
||||
public string $oidcScopes = 'openid profile email';
|
||||
|
||||
/**
|
||||
* @var bool create user
|
||||
*
|
||||
* Create user if not exists
|
||||
*/
|
||||
public bool $oidcCreateUser = false;
|
||||
|
||||
/**
|
||||
* @var int OIDC
|
||||
*
|
||||
* Default Role for new users
|
||||
*/
|
||||
public int $oidcDefaultRole = 20;
|
||||
|
||||
/**
|
||||
* @var string OIDC Field Email
|
||||
*/
|
||||
public string $oidcFieldEmail = 'email';
|
||||
|
||||
/**
|
||||
* @var string OIDC Field First Name
|
||||
*/
|
||||
public string $oidcFieldFirstName = 'given_name';
|
||||
|
||||
/**
|
||||
* @var string OIDC Field Last Name
|
||||
*/
|
||||
public string $oidcFieldLastName = 'family_name';
|
||||
|
||||
/**
|
||||
* @var string OIDC Field Phone
|
||||
*/
|
||||
public string $oidcFieldPhone = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Field Job Title
|
||||
*/
|
||||
public string $oidcFieldJobtitle = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Field Job Level
|
||||
*/
|
||||
public string $oidcFieldJoblevel = '';
|
||||
|
||||
/**
|
||||
* @var string OIDC Field Department
|
||||
*/
|
||||
public string $oidcFieldDepartment = '';
|
||||
|
||||
// Redis Settings ===============================================================================
|
||||
/**
|
||||
* @var bool Set to true if you want to use Redis
|
||||
*/
|
||||
public bool $useRedis = false;
|
||||
|
||||
/**
|
||||
* @var bool Set to true if you want to use a redis cluster
|
||||
*/
|
||||
public bool $useCluster = false;
|
||||
|
||||
/**
|
||||
* @var string Redis URL
|
||||
*/
|
||||
#[LaravelConfig('redis.default.url')]
|
||||
public string $redisUrl = '';
|
||||
|
||||
/**
|
||||
* @var string Redis Host
|
||||
*/
|
||||
#[LaravelConfig('redis.default.host')]
|
||||
public string $redisHost = '127.0.0.1';
|
||||
|
||||
/**
|
||||
* @var string Redis Port
|
||||
*/
|
||||
#[LaravelConfig('redis.default.port')]
|
||||
public string $redisPort = '6379';
|
||||
|
||||
/**
|
||||
* @var string Redis Password
|
||||
*/
|
||||
#[LaravelConfig('redis.default.password')]
|
||||
public string $redisPassword = '';
|
||||
|
||||
/**
|
||||
* @var string Redis Password
|
||||
*/
|
||||
#[LaravelConfig('redis.default.tls')]
|
||||
public string $redisScheme = 'tls';
|
||||
|
||||
// Security/Rate Limiting Settings ===============================================================================
|
||||
/**
|
||||
* @var string trusted Proxies
|
||||
*/
|
||||
public string $trustedProxies = '127.0.0.1,REMOTE_ADDR';
|
||||
|
||||
/**
|
||||
* @var int rate limit on all requests
|
||||
*/
|
||||
public int $ratelimitGeneral = 2000;
|
||||
|
||||
/**
|
||||
* @var int rate limit on API requests (per user+IP per minute). 120 = 2 req/s sustained —
|
||||
* enough for mobile-app sync bursts and integration polling while still catching
|
||||
* runaway scripts; in line with comparable tools (GitHub ~83/min, Jira ~100/min).
|
||||
*/
|
||||
public int $ratelimitApi = 120;
|
||||
|
||||
/**
|
||||
* @var int rate limit on auth requests
|
||||
*/
|
||||
public int $ratelimitAuth = 20;
|
||||
|
||||
/**
|
||||
* @var int rate limit on MCP endpoint requests (per user+IP per minute). Higher than the API
|
||||
* limit because agentic LLM clients burst many parallel tool calls per turn.
|
||||
*/
|
||||
public int $ratelimitMcp = 300;
|
||||
|
||||
/**
|
||||
* @var int rate limit on signup + user-invite POSTs (per IP per minute). These endpoints
|
||||
* send email and provision resources, so they get a tight budget (invite-spam abuse).
|
||||
*/
|
||||
public int $ratelimitSignup = 5;
|
||||
|
||||
/**
|
||||
* @var int maximum user invites per inviting user per hour
|
||||
*/
|
||||
public int $ratelimitInvitesUser = 10;
|
||||
|
||||
/**
|
||||
* @var int maximum user invites per installation per day. Raise for legitimate bulk
|
||||
* onboarding (CSV/directory imports).
|
||||
*/
|
||||
public int $ratelimitInvitesTenant = 30;
|
||||
}
|
||||
183
app/Core/Configuration/Environment.php
Normal file
183
app/Core/Configuration/Environment.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Configuration;
|
||||
|
||||
use ArrayAccess;
|
||||
use Exception;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigContract;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Config\Config;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* environment - class To handle environment variables
|
||||
*/
|
||||
class Environment extends Repository implements ArrayAccess, ConfigContract
|
||||
{
|
||||
// Config Files ===============================================================================
|
||||
|
||||
private ?object $yaml;
|
||||
|
||||
private ?Config $phpConfig;
|
||||
|
||||
/**
|
||||
* @var array list of legacy mappings
|
||||
*
|
||||
* @todo warn about key changes after deprecating config/configuration.php
|
||||
* @todo remove this after removing support for config/configuration.php
|
||||
*/
|
||||
private const LEGACY_MAPPINGS = [
|
||||
'printLogoUrl' => 'LEAN_PRINT_LOGO_URL',
|
||||
'primarycolor' => 'LEAN_PRIMARY_COLOR',
|
||||
'secondarycolor' => 'LEAN_SECONDARY_COLOR',
|
||||
'email' => 'LEAN_EMAIL_RETURN',
|
||||
'useSMTP' => 'LEAN_EMAIL_USE_SMTP',
|
||||
'smtpHosts' => 'LEAN_EMAIL_SMTP_HOSTS',
|
||||
'smtpAuth' => 'LEAN_EMAIL_SMTP_AUTH',
|
||||
'smtpUsername' => 'LEAN_EMAIL_SMTP_USERNAME',
|
||||
'smtpPassword' => 'LEAN_EMAIL_SMTP_PASSWORD',
|
||||
'smtpAutoTLS' => 'LEAN_EMAIL_SMTP_AUTO_TLS',
|
||||
'smtpSecure' => 'LEAN_EMAIL_SMTP_SECURE',
|
||||
'smtpPort' => 'LEAN_EMAIL_SMTP_PORT',
|
||||
'smtpSSLNoverify' => 'LEAN_EMAIL_SMTP_SSLNOVERIFY',
|
||||
'useLdap' => 'LEAN_LDAP_USE_LDAP',
|
||||
'ldapType' => 'LEAN_LDAP_LDAP_TYPE',
|
||||
'ldapLtGroupAssignments' => 'LEAN_LDAP_GROUP_ASSIGNMENT',
|
||||
'ldapDomain' => 'LEAN_LDAP_LDAP_DOMAIN',
|
||||
'oidcClientId' => 'LEAN_OIDC_CLIENT_ID',
|
||||
'oidcClientSecret' => 'LEAN_OIDC_CLIENT_SECRET',
|
||||
'oidcAutoDiscoverUrl' => 'LEAN_OIDC_AUTO_DISCOVER',
|
||||
'oidcAuthUrl' => 'LEAN_OIDC_AUTH_URL_OVERRIDE',
|
||||
'oidcTokenUrl' => 'LEAN_OIDC_TOKEN_URL_OVERRIDE',
|
||||
'oidcJwksUrl' => 'LEAN_OIDC_JWKS_URL_OVERRIDE',
|
||||
'oidcUserInfoUrl' => 'LEAN_OIDC_USERINFO_URL_OVERRIDE',
|
||||
'oidcFieldFirstName' => 'LEAN_OIDC_FIELD_FIRSTNAME',
|
||||
'oidcFieldLastName' => 'LEAN_OIDC_FIELD_LASTNAME',
|
||||
'redisURL' => 'LEAN_REDIS_URL',
|
||||
];
|
||||
|
||||
/**
|
||||
* environment constructor.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct(array $items = [])
|
||||
{
|
||||
if (! empty($items) && is_array($items)) {
|
||||
$this->items = $items;
|
||||
}
|
||||
|
||||
$defaultConfiguration = new DefaultConfig;
|
||||
|
||||
/* PHP */
|
||||
$this->phpConfig = null;
|
||||
if (file_exists($phpConfigFile = APP_ROOT.'/config/configuration.php')) {
|
||||
|
||||
require_once $phpConfigFile;
|
||||
|
||||
if (! class_exists(Config::class)) {
|
||||
throw new Exception('We found a php configuration file but the class cannot be instantiated. Please check the configuration file for namespace and class name. You can use the configuration.sample.php as a template. See https://github.com/leantime/leantime/releases/tag/v2.4-beta-2 for more details.');
|
||||
}
|
||||
|
||||
$this->phpConfig = new Config;
|
||||
|
||||
$configVars = get_class_vars(Config::class);
|
||||
foreach (array_keys($configVars) as $propertyName) {
|
||||
$envVarName = self::LEGACY_MAPPINGS[$propertyName] ?? 'LEAN_'.Str::of($propertyName)->snake()->upper()->toString();
|
||||
putenv($envVarName.'='.$configVars[$propertyName]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$defaultConfigurationProperties = get_class_vars($defaultConfiguration::class);
|
||||
|
||||
foreach (array_keys($defaultConfigurationProperties) as $propertyName) {
|
||||
|
||||
$type = gettype($defaultConfigurationProperties[$propertyName]);
|
||||
$type = $type == 'NULL' ? 'string' : $type;
|
||||
|
||||
$this->set($propertyName, $this->environmentHelper(
|
||||
envVar: self::LEGACY_MAPPINGS[$propertyName] ?? 'LEAN_'.Str::of($propertyName)->snake()->upper()->toString(),
|
||||
default: $defaultConfigurationProperties[$propertyName],
|
||||
dataType: $type,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* environmentHelper - helper function to get a value from the environment
|
||||
*/
|
||||
private function environmentHelper(string $envVar, mixed $default, string $dataType = 'string'): mixed
|
||||
{
|
||||
/**
|
||||
* Basically, here, we are doing the fetch order of
|
||||
* environment -> .env file -> yaml file -> user default -> leantime default
|
||||
* This allows us to use any one or a combination of those methods to configure leantime.
|
||||
*/
|
||||
$found = $default;
|
||||
$found = $this->tryGetFromPhp($envVar, $found) ?? $found;
|
||||
$found = $this->tryGetFromEnvironment($envVar, $found) ?? $found;
|
||||
|
||||
// we need to check to see if we need to convert the found data
|
||||
return match ($dataType) {
|
||||
'string' => $found,
|
||||
'boolean' => filter_var($found, FILTER_VALIDATE_BOOLEAN),
|
||||
'number' => (int) ($found),
|
||||
default => $found,
|
||||
};
|
||||
}
|
||||
|
||||
private function tryGetFromPhp(string $envVar, mixed $currentValue): mixed
|
||||
{
|
||||
|
||||
if ($this->phpConfig) {
|
||||
$key = array_search($envVar, self::LEGACY_MAPPINGS) ?: Str::of($envVar)->replace('LEAN_', '')->lower()->camel()->toString();
|
||||
|
||||
return $this->phpConfig->$key ?? $currentValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* tryGetFromEnvironment - try to get a value from the environment
|
||||
*/
|
||||
private function tryGetFromEnvironment(string $envVar, mixed $currentValue): mixed
|
||||
{
|
||||
return $_ENV[$envVar] ?? env($envVar) ?? $currentValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically access the configuration using object syntax.
|
||||
*/
|
||||
public function __get(string $key): mixed
|
||||
{
|
||||
return $this->get($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically set the configuration using object syntax.
|
||||
*/
|
||||
public function __set(string $key, mixed $value): void
|
||||
{
|
||||
$this->set($key, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically check if a configuration option is set using object syntax.
|
||||
*/
|
||||
public function __isset(string $key): bool
|
||||
{
|
||||
return $this->has($key) && $this->get($key) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically unset a configuration option using object syntax.
|
||||
*/
|
||||
public function __unset(string $key): void
|
||||
{
|
||||
$this->set($key, null);
|
||||
}
|
||||
}
|
||||
30
app/Core/Configuration/EnvironmentServiceProvider.php
Normal file
30
app/Core/Configuration/EnvironmentServiceProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Configuration;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
|
||||
class EnvironmentServiceProvider extends ServiceProvider
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* Register any application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->singleton(
|
||||
\Leantime\Core\Configuration\AppSettings::class, \Leantime\Core\Configuration\AppSettings::class);
|
||||
$this->app->singleton(
|
||||
\Leantime\Core\Configuration\Environment::class, \Leantime\Core\Configuration\Environment::class);
|
||||
|
||||
}
|
||||
|
||||
public function boot()
|
||||
{
|
||||
self::dispatchEvent('config_initialized');
|
||||
}
|
||||
}
|
||||
32
app/Core/Configuration/EnvironmentsEnum.php
Normal file
32
app/Core/Configuration/EnvironmentsEnum.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Configuration;
|
||||
|
||||
/**
|
||||
* Enum class Environment options
|
||||
*
|
||||
* Enum representing the available environment configs
|
||||
*/
|
||||
enum EnvironmentsEnum: string
|
||||
{
|
||||
/**
|
||||
* Set to dev environment
|
||||
*/
|
||||
case Dev = 'dev';
|
||||
|
||||
/**
|
||||
* Set to staging environment
|
||||
*/
|
||||
case Staging = 'staging';
|
||||
|
||||
/**
|
||||
* Set to oss release environment
|
||||
*/
|
||||
case Oss = 'oss';
|
||||
|
||||
/**
|
||||
* Set to prod environment
|
||||
*/
|
||||
case Production = 'production';
|
||||
|
||||
}
|
||||
1017
app/Core/Configuration/laravelConfig.php
Normal file
1017
app/Core/Configuration/laravelConfig.php
Normal file
File diff suppressed because it is too large
Load Diff
95
app/Core/Console/Application.php
Normal file
95
app/Core/Console/Application.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Console;
|
||||
|
||||
use Illuminate\Console\Command as IlluminateCommand;
|
||||
use Illuminate\Console\Events\ArtisanStarting;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\ProcessUtils;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Symfony\Component\Console\Command\Command as SymfonyCommand;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Application extends \Illuminate\Console\Application
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected $commandsLoaded = false;
|
||||
|
||||
public function __construct(Container $laravel, Dispatcher $events, $version)
|
||||
{
|
||||
|
||||
$parent = get_parent_class(\Illuminate\Console\Application::class);
|
||||
$parent::__construct('Leantime CLI (extends Laravel)', $version);
|
||||
|
||||
$this->laravel = $laravel;
|
||||
$this->events = $events;
|
||||
$this->setAutoExit(false);
|
||||
$this->setCatchExceptions(false);
|
||||
|
||||
$this->events->dispatch(new ArtisanStarting($this));
|
||||
|
||||
$this->bootstrap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the Laravel container into commands as they are registered.
|
||||
*
|
||||
* symfony/console 7.4 renamed add() to addCommand() and routes the lazy command-loader
|
||||
* path (Application::has() -> commandLoader->get()) through addCommand(), but
|
||||
* Illuminate\Console\Application only overrides the deprecated add() — the single place
|
||||
* setLaravel() is called. The result is that lazily-resolved #[AsCommand] commands run
|
||||
* with a null $laravel and fatal in Command::run(). Mirroring Illuminate's add() here on
|
||||
* addCommand() closes that gap for every registration path (add() delegates here too).
|
||||
*/
|
||||
public function addCommand(callable|SymfonyCommand $command): ?SymfonyCommand
|
||||
{
|
||||
if ($command instanceof IlluminateCommand) {
|
||||
$command->setLaravel($this->laravel);
|
||||
}
|
||||
|
||||
return parent::addCommand($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the current application.
|
||||
*
|
||||
* @return int 0 if everything went fine, or an error code
|
||||
*/
|
||||
public function doRun(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
// $this->setDomain($input);
|
||||
|
||||
self::dispatchEvent('beforeRun', ['application' => $this, 'input' => $input, 'output' => $output]);
|
||||
|
||||
/* wrapper for future use */
|
||||
return parent::doRun($input, $output);
|
||||
}
|
||||
|
||||
protected function getDefaultInputDefinition(): InputDefinition
|
||||
{
|
||||
$definition = parent::getDefaultInputDefinition();
|
||||
|
||||
$definition->addOption(new InputOption('--domain', null, InputOption::VALUE_OPTIONAL, 'Set domain for config'));
|
||||
|
||||
return $definition;
|
||||
|
||||
}
|
||||
|
||||
protected function bootstrap()
|
||||
{
|
||||
|
||||
foreach (static::$bootstrappers as $bootstrapper) {
|
||||
$bootstrapper($this);
|
||||
}
|
||||
}
|
||||
|
||||
public static function artisanBinary()
|
||||
{
|
||||
return ProcessUtils::escapeArgument('bin/leantime');
|
||||
}
|
||||
}
|
||||
11
app/Core/Console/CliRequest.php
Normal file
11
app/Core/Console/CliRequest.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Console;
|
||||
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
|
||||
class CliRequest extends IncomingRequest
|
||||
{
|
||||
//
|
||||
public function handle() {}
|
||||
}
|
||||
835
app/Core/Console/CliServiceProvider.php
Normal file
835
app/Core/Console/CliServiceProvider.php
Normal file
@@ -0,0 +1,835 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Console;
|
||||
|
||||
use Illuminate\Auth\Console\ClearResetsCommand;
|
||||
use Illuminate\Cache\Console\CacheTableCommand;
|
||||
use Illuminate\Cache\Console\ClearCommand as CacheClearCommand;
|
||||
use Illuminate\Cache\Console\ForgetCommand as CacheForgetCommand;
|
||||
use Illuminate\Cache\Console\PruneStaleTagsCommand;
|
||||
use Illuminate\Console\Scheduling\ScheduleClearCacheCommand;
|
||||
use Illuminate\Console\Scheduling\ScheduleFinishCommand;
|
||||
use Illuminate\Console\Scheduling\ScheduleInterruptCommand;
|
||||
use Illuminate\Console\Scheduling\ScheduleListCommand;
|
||||
use Illuminate\Console\Scheduling\ScheduleRunCommand;
|
||||
use Illuminate\Console\Scheduling\ScheduleTestCommand;
|
||||
use Illuminate\Console\Scheduling\ScheduleWorkCommand;
|
||||
use Illuminate\Console\Signals;
|
||||
use Illuminate\Contracts\Support\DeferrableProvider;
|
||||
use Illuminate\Database\Console\DbCommand;
|
||||
use Illuminate\Database\Console\DumpCommand;
|
||||
use Illuminate\Database\Console\Factories\FactoryMakeCommand;
|
||||
use Illuminate\Database\Console\MonitorCommand as DatabaseMonitorCommand;
|
||||
use Illuminate\Database\Console\PruneCommand;
|
||||
use Illuminate\Database\Console\Seeds\SeedCommand;
|
||||
use Illuminate\Database\Console\Seeds\SeederMakeCommand;
|
||||
use Illuminate\Database\Console\ShowCommand;
|
||||
use Illuminate\Database\Console\ShowModelCommand;
|
||||
use Illuminate\Database\Console\TableCommand as DatabaseTableCommand;
|
||||
use Illuminate\Database\Console\WipeCommand;
|
||||
use Illuminate\Foundation\Console\AboutCommand;
|
||||
use Illuminate\Foundation\Console\CastMakeCommand;
|
||||
use Illuminate\Foundation\Console\ChannelListCommand;
|
||||
use Illuminate\Foundation\Console\ChannelMakeCommand;
|
||||
use Illuminate\Foundation\Console\ClearCompiledCommand;
|
||||
use Illuminate\Foundation\Console\ComponentMakeCommand;
|
||||
use Illuminate\Foundation\Console\ConfigCacheCommand;
|
||||
use Illuminate\Foundation\Console\ConfigClearCommand;
|
||||
use Illuminate\Foundation\Console\ConfigShowCommand;
|
||||
use Illuminate\Foundation\Console\ConsoleMakeCommand;
|
||||
use Illuminate\Foundation\Console\DocsCommand;
|
||||
use Illuminate\Foundation\Console\DownCommand;
|
||||
use Illuminate\Foundation\Console\EnvironmentCommand;
|
||||
use Illuminate\Foundation\Console\EnvironmentDecryptCommand;
|
||||
use Illuminate\Foundation\Console\EnvironmentEncryptCommand;
|
||||
use Illuminate\Foundation\Console\EventCacheCommand;
|
||||
use Illuminate\Foundation\Console\EventClearCommand;
|
||||
use Illuminate\Foundation\Console\EventGenerateCommand;
|
||||
use Illuminate\Foundation\Console\EventListCommand;
|
||||
use Illuminate\Foundation\Console\EventMakeCommand;
|
||||
use Illuminate\Foundation\Console\ExceptionMakeCommand;
|
||||
use Illuminate\Foundation\Console\JobMakeCommand;
|
||||
use Illuminate\Foundation\Console\KeyGenerateCommand;
|
||||
use Illuminate\Foundation\Console\LangPublishCommand;
|
||||
use Illuminate\Foundation\Console\ListenerMakeCommand;
|
||||
use Illuminate\Foundation\Console\MailMakeCommand;
|
||||
use Illuminate\Foundation\Console\ModelMakeCommand;
|
||||
use Illuminate\Foundation\Console\NotificationMakeCommand;
|
||||
use Illuminate\Foundation\Console\ObserverMakeCommand;
|
||||
use Illuminate\Foundation\Console\OptimizeClearCommand;
|
||||
use Illuminate\Foundation\Console\OptimizeCommand;
|
||||
use Illuminate\Foundation\Console\PackageDiscoverCommand;
|
||||
use Illuminate\Foundation\Console\PolicyMakeCommand;
|
||||
use Illuminate\Foundation\Console\ProviderMakeCommand;
|
||||
use Illuminate\Foundation\Console\RequestMakeCommand;
|
||||
use Illuminate\Foundation\Console\ResourceMakeCommand;
|
||||
use Illuminate\Foundation\Console\RouteCacheCommand;
|
||||
use Illuminate\Foundation\Console\RouteClearCommand;
|
||||
use Illuminate\Foundation\Console\RouteListCommand;
|
||||
use Illuminate\Foundation\Console\RuleMakeCommand;
|
||||
use Illuminate\Foundation\Console\ScopeMakeCommand;
|
||||
use Illuminate\Foundation\Console\ServeCommand;
|
||||
use Illuminate\Foundation\Console\StorageLinkCommand;
|
||||
use Illuminate\Foundation\Console\StorageUnlinkCommand;
|
||||
use Illuminate\Foundation\Console\StubPublishCommand;
|
||||
use Illuminate\Foundation\Console\TestMakeCommand;
|
||||
use Illuminate\Foundation\Console\UpCommand;
|
||||
use Illuminate\Foundation\Console\VendorPublishCommand;
|
||||
use Illuminate\Foundation\Console\ViewCacheCommand;
|
||||
use Illuminate\Foundation\Console\ViewClearCommand;
|
||||
use Illuminate\Foundation\Console\ViewMakeCommand;
|
||||
use Illuminate\Notifications\Console\NotificationTableCommand;
|
||||
use Illuminate\Queue\Console\BatchesTableCommand;
|
||||
use Illuminate\Queue\Console\ClearCommand as QueueClearCommand;
|
||||
use Illuminate\Queue\Console\FailedTableCommand;
|
||||
use Illuminate\Queue\Console\FlushFailedCommand as FlushFailedQueueCommand;
|
||||
use Illuminate\Queue\Console\ForgetFailedCommand as ForgetFailedQueueCommand;
|
||||
use Illuminate\Queue\Console\ListenCommand as QueueListenCommand;
|
||||
use Illuminate\Queue\Console\ListFailedCommand as ListFailedQueueCommand;
|
||||
use Illuminate\Queue\Console\MonitorCommand as QueueMonitorCommand;
|
||||
use Illuminate\Queue\Console\PruneBatchesCommand as QueuePruneBatchesCommand;
|
||||
use Illuminate\Queue\Console\PruneFailedJobsCommand as QueuePruneFailedJobsCommand;
|
||||
use Illuminate\Queue\Console\RestartCommand as QueueRestartCommand;
|
||||
use Illuminate\Queue\Console\RetryBatchCommand as QueueRetryBatchCommand;
|
||||
use Illuminate\Queue\Console\RetryCommand as QueueRetryCommand;
|
||||
use Illuminate\Queue\Console\TableCommand;
|
||||
use Illuminate\Queue\Console\WorkCommand as QueueWorkCommand;
|
||||
use Illuminate\Routing\Console\ControllerMakeCommand;
|
||||
use Illuminate\Routing\Console\MiddlewareMakeCommand;
|
||||
use Illuminate\Session\Console\SessionTableCommand;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class CliServiceProvider extends ServiceProvider implements DeferrableProvider
|
||||
{
|
||||
/**
|
||||
* The commands to be registered.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $commands = [
|
||||
'About' => AboutCommand::class,
|
||||
'CacheClear' => CacheClearCommand::class,
|
||||
'CacheForget' => CacheForgetCommand::class,
|
||||
'ClearCompiled' => ClearCompiledCommand::class,
|
||||
'ClearResets' => ClearResetsCommand::class,
|
||||
'ConfigCache' => ConfigCacheCommand::class,
|
||||
'ConfigClear' => ConfigClearCommand::class,
|
||||
'ConfigShow' => ConfigShowCommand::class,
|
||||
'Db' => DbCommand::class,
|
||||
'DbMonitor' => DatabaseMonitorCommand::class,
|
||||
'DbPrune' => PruneCommand::class,
|
||||
'DbShow' => ShowCommand::class,
|
||||
'DbTable' => DatabaseTableCommand::class,
|
||||
// 'DbWipe' => WipeCommand::class,
|
||||
'Down' => DownCommand::class,
|
||||
'Environment' => EnvironmentCommand::class,
|
||||
'EnvironmentDecrypt' => EnvironmentDecryptCommand::class,
|
||||
'EnvironmentEncrypt' => EnvironmentEncryptCommand::class,
|
||||
'EventCache' => EventCacheCommand::class,
|
||||
'EventClear' => EventClearCommand::class,
|
||||
'EventList' => EventListCommand::class,
|
||||
'KeyGenerate' => KeyGenerateCommand::class,
|
||||
'Optimize' => OptimizeCommand::class,
|
||||
'OptimizeClear' => OptimizeClearCommand::class,
|
||||
'PackageDiscover' => PackageDiscoverCommand::class,
|
||||
'PruneStaleTagsCommand' => PruneStaleTagsCommand::class,
|
||||
'QueueClear' => QueueClearCommand::class,
|
||||
'QueueFailed' => ListFailedQueueCommand::class,
|
||||
'QueueFlush' => FlushFailedQueueCommand::class,
|
||||
'QueueForget' => ForgetFailedQueueCommand::class,
|
||||
// 'QueueListen' => QueueListenCommand::class,
|
||||
// 'QueueMonitor' => QueueMonitorCommand::class,
|
||||
// 'QueuePruneBatches' => QueuePruneBatchesCommand::class,
|
||||
// 'QueuePruneFailedJobs' => QueuePruneFailedJobsCommand::class,
|
||||
// 'QueueRestart' => QueueRestartCommand::class,
|
||||
// 'QueueRetry' => QueueRetryCommand::class,
|
||||
// 'QueueRetryBatch' => QueueRetryBatchCommand::class,
|
||||
// 'QueueWork' => QueueWorkCommand::class,
|
||||
// 'RouteCache' => RouteCacheCommand::class,
|
||||
// 'RouteClear' => RouteClearCommand::class,
|
||||
// 'RouteList' => RouteListCommand::class,
|
||||
'SchemaDump' => DumpCommand::class,
|
||||
'Seed' => SeedCommand::class,
|
||||
'ScheduleFinish' => ScheduleFinishCommand::class,
|
||||
'ScheduleList' => ScheduleListCommand::class,
|
||||
'ScheduleRun' => ScheduleRunCommand::class,
|
||||
'ScheduleClearCache' => ScheduleClearCacheCommand::class,
|
||||
'ScheduleTest' => ScheduleTestCommand::class,
|
||||
'ScheduleWork' => ScheduleWorkCommand::class,
|
||||
'ScheduleInterrupt' => ScheduleInterruptCommand::class,
|
||||
'ShowModel' => ShowModelCommand::class,
|
||||
'StorageLink' => StorageLinkCommand::class,
|
||||
'StorageUnlink' => StorageUnlinkCommand::class,
|
||||
'Up' => UpCommand::class,
|
||||
'ViewCache' => ViewCacheCommand::class,
|
||||
'ViewClear' => ViewClearCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The commands to be registered.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $devCommands = [
|
||||
'CacheTable' => CacheTableCommand::class,
|
||||
'CastMake' => CastMakeCommand::class,
|
||||
'ChannelList' => ChannelListCommand::class,
|
||||
'ChannelMake' => ChannelMakeCommand::class,
|
||||
'ComponentMake' => ComponentMakeCommand::class,
|
||||
'ConsoleMake' => ConsoleMakeCommand::class,
|
||||
'ControllerMake' => ControllerMakeCommand::class,
|
||||
'Docs' => DocsCommand::class,
|
||||
'EventGenerate' => EventGenerateCommand::class,
|
||||
'EventMake' => EventMakeCommand::class,
|
||||
'ExceptionMake' => ExceptionMakeCommand::class,
|
||||
'FactoryMake' => FactoryMakeCommand::class,
|
||||
'JobMake' => JobMakeCommand::class,
|
||||
'LangPublish' => LangPublishCommand::class,
|
||||
'ListenerMake' => ListenerMakeCommand::class,
|
||||
'MailMake' => MailMakeCommand::class,
|
||||
'MiddlewareMake' => MiddlewareMakeCommand::class,
|
||||
'ModelMake' => ModelMakeCommand::class,
|
||||
'NotificationMake' => NotificationMakeCommand::class,
|
||||
'NotificationTable' => NotificationTableCommand::class,
|
||||
'ObserverMake' => ObserverMakeCommand::class,
|
||||
'PolicyMake' => PolicyMakeCommand::class,
|
||||
'ProviderMake' => ProviderMakeCommand::class,
|
||||
'QueueFailedTable' => FailedTableCommand::class,
|
||||
'QueueTable' => TableCommand::class,
|
||||
'QueueBatchesTable' => BatchesTableCommand::class,
|
||||
'RequestMake' => RequestMakeCommand::class,
|
||||
'ResourceMake' => ResourceMakeCommand::class,
|
||||
'RuleMake' => RuleMakeCommand::class,
|
||||
'ScopeMake' => ScopeMakeCommand::class,
|
||||
'SeederMake' => SeederMakeCommand::class,
|
||||
'SessionTable' => SessionTableCommand::class,
|
||||
'Serve' => ServeCommand::class,
|
||||
'StubPublish' => StubPublishCommand::class,
|
||||
'TestMake' => TestMakeCommand::class,
|
||||
'VendorPublish' => VendorPublishCommand::class,
|
||||
'ViewMake' => ViewMakeCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->registerCommands(array_merge(
|
||||
$this->commands,
|
||||
$this->devCommands
|
||||
));
|
||||
|
||||
Signals::resolveAvailabilityUsing(function () {
|
||||
return $this->app->runningInConsole()
|
||||
&& ! $this->app->runningUnitTests()
|
||||
&& extension_loaded('pcntl');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the given commands.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerCommands(array $commands)
|
||||
{
|
||||
foreach ($commands as $commandName => $command) {
|
||||
$method = "register{$commandName}Command";
|
||||
|
||||
if (method_exists($this, $method)) {
|
||||
$this->{$method}();
|
||||
} else {
|
||||
$this->app->singleton($command);
|
||||
}
|
||||
}
|
||||
|
||||
$this->commands(array_values($commands));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerAboutCommand()
|
||||
{
|
||||
$this->app->singleton(AboutCommand::class, function ($app) {
|
||||
return new AboutCommand($app['composer']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerCacheClearCommand()
|
||||
{
|
||||
$this->app->singleton(CacheClearCommand::class, function ($app) {
|
||||
return new CacheClearCommand($app['cache'], $app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerCacheForgetCommand()
|
||||
{
|
||||
$this->app->singleton(CacheForgetCommand::class, function ($app) {
|
||||
return new CacheForgetCommand($app['cache']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerCacheTableCommand()
|
||||
{
|
||||
$this->app->singleton(CacheTableCommand::class, function ($app) {
|
||||
return new CacheTableCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerCastMakeCommand()
|
||||
{
|
||||
$this->app->singleton(CastMakeCommand::class, function ($app) {
|
||||
return new CastMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerChannelMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ChannelMakeCommand::class, function ($app) {
|
||||
return new ChannelMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerComponentMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ComponentMakeCommand::class, function ($app) {
|
||||
return new ComponentMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerConfigCacheCommand()
|
||||
{
|
||||
$this->app->singleton(ConfigCacheCommand::class, function ($app) {
|
||||
return new ConfigCacheCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerConfigClearCommand()
|
||||
{
|
||||
$this->app->singleton(ConfigClearCommand::class, function ($app) {
|
||||
return new ConfigClearCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerConsoleMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ConsoleMakeCommand::class, function ($app) {
|
||||
return new ConsoleMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerControllerMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ControllerMakeCommand::class, function ($app) {
|
||||
return new ControllerMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerEventMakeCommand()
|
||||
{
|
||||
$this->app->singleton(EventMakeCommand::class, function ($app) {
|
||||
return new EventMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerExceptionMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ExceptionMakeCommand::class, function ($app) {
|
||||
return new ExceptionMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerFactoryMakeCommand()
|
||||
{
|
||||
$this->app->singleton(FactoryMakeCommand::class, function ($app) {
|
||||
return new FactoryMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerEventClearCommand()
|
||||
{
|
||||
$this->app->singleton(EventClearCommand::class, function ($app) {
|
||||
return new EventClearCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerJobMakeCommand()
|
||||
{
|
||||
$this->app->singleton(JobMakeCommand::class, function ($app) {
|
||||
return new JobMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerListenerMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ListenerMakeCommand::class, function ($app) {
|
||||
return new ListenerMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerMailMakeCommand()
|
||||
{
|
||||
$this->app->singleton(MailMakeCommand::class, function ($app) {
|
||||
return new MailMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerMiddlewareMakeCommand()
|
||||
{
|
||||
$this->app->singleton(MiddlewareMakeCommand::class, function ($app) {
|
||||
return new MiddlewareMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerModelMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ModelMakeCommand::class, function ($app) {
|
||||
return new ModelMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerNotificationMakeCommand()
|
||||
{
|
||||
$this->app->singleton(NotificationMakeCommand::class, function ($app) {
|
||||
return new NotificationMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerNotificationTableCommand()
|
||||
{
|
||||
$this->app->singleton(NotificationTableCommand::class, function ($app) {
|
||||
return new NotificationTableCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerObserverMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ObserverMakeCommand::class, function ($app) {
|
||||
return new ObserverMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerPolicyMakeCommand()
|
||||
{
|
||||
$this->app->singleton(PolicyMakeCommand::class, function ($app) {
|
||||
return new PolicyMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerProviderMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ProviderMakeCommand::class, function ($app) {
|
||||
return new ProviderMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueueForgetCommand()
|
||||
{
|
||||
$this->app->singleton(ForgetFailedQueueCommand::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueueListenCommand()
|
||||
{
|
||||
$this->app->singleton(QueueListenCommand::class, function ($app) {
|
||||
return new QueueListenCommand($app['queue.listener']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueueMonitorCommand()
|
||||
{
|
||||
$this->app->singleton(QueueMonitorCommand::class, function ($app) {
|
||||
return new QueueMonitorCommand($app['queue'], $app['events']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueuePruneBatchesCommand()
|
||||
{
|
||||
$this->app->singleton(QueuePruneBatchesCommand::class, function () {
|
||||
return new QueuePruneBatchesCommand;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueuePruneFailedJobsCommand()
|
||||
{
|
||||
$this->app->singleton(QueuePruneFailedJobsCommand::class, function () {
|
||||
return new QueuePruneFailedJobsCommand;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueueRestartCommand()
|
||||
{
|
||||
$this->app->singleton(QueueRestartCommand::class, function ($app) {
|
||||
return new QueueRestartCommand($app['cache.store']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueueWorkCommand()
|
||||
{
|
||||
$this->app->singleton(QueueWorkCommand::class, function ($app) {
|
||||
return new QueueWorkCommand($app['queue.worker'], $app['cache.store']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueueFailedTableCommand()
|
||||
{
|
||||
$this->app->singleton(FailedTableCommand::class, function ($app) {
|
||||
return new FailedTableCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueueTableCommand()
|
||||
{
|
||||
$this->app->singleton(TableCommand::class, function ($app) {
|
||||
return new TableCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerQueueBatchesTableCommand()
|
||||
{
|
||||
$this->app->singleton(BatchesTableCommand::class, function ($app) {
|
||||
return new BatchesTableCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerRequestMakeCommand()
|
||||
{
|
||||
$this->app->singleton(RequestMakeCommand::class, function ($app) {
|
||||
return new RequestMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerResourceMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ResourceMakeCommand::class, function ($app) {
|
||||
return new ResourceMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerRuleMakeCommand()
|
||||
{
|
||||
$this->app->singleton(RuleMakeCommand::class, function ($app) {
|
||||
return new RuleMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerScopeMakeCommand()
|
||||
{
|
||||
$this->app->singleton(ScopeMakeCommand::class, function ($app) {
|
||||
return new ScopeMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerSeederMakeCommand()
|
||||
{
|
||||
$this->app->singleton(SeederMakeCommand::class, function ($app) {
|
||||
return new SeederMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerSessionTableCommand()
|
||||
{
|
||||
$this->app->singleton(SessionTableCommand::class, function ($app) {
|
||||
return new SessionTableCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerRouteCacheCommand()
|
||||
{
|
||||
$this->app->singleton(RouteCacheCommand::class, function ($app) {
|
||||
return new RouteCacheCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerRouteClearCommand()
|
||||
{
|
||||
$this->app->singleton(RouteClearCommand::class, function ($app) {
|
||||
return new RouteClearCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerRouteListCommand()
|
||||
{
|
||||
$this->app->singleton(RouteListCommand::class, function ($app) {
|
||||
return new RouteListCommand($app['router']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerSeedCommand()
|
||||
{
|
||||
$this->app->singleton(SeedCommand::class, function ($app) {
|
||||
return new SeedCommand($app['db']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerTestMakeCommand()
|
||||
{
|
||||
$this->app->singleton(TestMakeCommand::class, function ($app) {
|
||||
return new TestMakeCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerVendorPublishCommand()
|
||||
{
|
||||
$this->app->singleton(VendorPublishCommand::class, function ($app) {
|
||||
return new VendorPublishCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerViewClearCommand()
|
||||
{
|
||||
$this->app->singleton(ViewClearCommand::class, function ($app) {
|
||||
return new ViewClearCommand($app['files']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the services provided by the provider.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function provides()
|
||||
{
|
||||
return array_merge(array_values($this->commands), array_values($this->devCommands));
|
||||
}
|
||||
}
|
||||
256
app/Core/Console/ConsoleKernel.php
Normal file
256
app/Core/Console/ConsoleKernel.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Console;
|
||||
|
||||
use Illuminate\Console\Application as Artisan;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Contracts\Console\Kernel as ConsoleKernelContract;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Foundation\Console\Kernel;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Console\Application as LeantimeCli;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
class ConsoleKernel extends Kernel implements ConsoleKernelContract
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected $app;
|
||||
|
||||
protected $artisan;
|
||||
|
||||
protected $commandStartedAt;
|
||||
|
||||
protected $bootstrappers = [
|
||||
\Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
|
||||
\Leantime\Core\Bootstrap\LoadConfig::class,
|
||||
\Illuminate\Foundation\Bootstrap\HandleExceptions::class,
|
||||
\Illuminate\Foundation\Bootstrap\RegisterFacades::class,
|
||||
\Leantime\Core\Bootstrap\SetRequestForConsole::class,
|
||||
\Illuminate\Foundation\Bootstrap\RegisterProviders::class,
|
||||
\Illuminate\Foundation\Bootstrap\BootProviders::class,
|
||||
];
|
||||
|
||||
public function __construct(Application $app, Dispatcher $events)
|
||||
{
|
||||
if (! defined('ARTISAN_BINARY')) {
|
||||
define('ARTISAN_BINARY', 'bin/leantime');
|
||||
}
|
||||
|
||||
parent::__construct($app, $events);
|
||||
}
|
||||
|
||||
public function bootstrap()
|
||||
{
|
||||
|
||||
if (! $this->app->hasBeenBootstrapped()) {
|
||||
$this->app->bootstrapWith($this->bootstrappers());
|
||||
}
|
||||
|
||||
$this->app->loadDeferredProviders();
|
||||
|
||||
if (! $this->commandsLoaded) {
|
||||
$this->commands();
|
||||
|
||||
if ($this->shouldDiscoverCommands()) {
|
||||
$this->discoverCommands();
|
||||
}
|
||||
|
||||
$this->commandsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle($input, $output = null)
|
||||
{
|
||||
$this->commandStartedAt = Carbon::now();
|
||||
|
||||
try {
|
||||
if (in_array($input->getFirstArgument(), ['env:encrypt', 'env:decrypt'], true)) {
|
||||
$this->bootstrapWithoutBootingProviders();
|
||||
}
|
||||
|
||||
if ($domain = $input->getParameterOption('--domain')) {
|
||||
$this->setDomain($domain);
|
||||
}
|
||||
|
||||
$this->bootstrap();
|
||||
|
||||
self::dispatch_event('console.bootstrapped', ['kernel' => $this, 'command' => $input]);
|
||||
|
||||
return $this->getArtisan()->run($input, $output);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
$this->reportException($e);
|
||||
|
||||
$this->renderException($output, $e);
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// We need to overwrite this because for some reason Laravel decided to only do command discovery if being called
|
||||
// from the original kernel
|
||||
protected function shouldDiscoverCommands()
|
||||
{
|
||||
return get_class($this) === __CLASS__;
|
||||
}
|
||||
|
||||
protected function discoverCommands()
|
||||
{
|
||||
|
||||
// Update standard commandPath
|
||||
$this->commandPaths = [
|
||||
APP_ROOT.'/app/Command/',
|
||||
];
|
||||
|
||||
foreach ($this->commandPaths as $path) {
|
||||
$this->load($path);
|
||||
}
|
||||
|
||||
// Load Dynamic command paths for leantime
|
||||
$ltCommands = collect(glob(APP_ROOT.'/app/Domain/**/Command/'));
|
||||
|
||||
// Load commands from enabled plugins
|
||||
try {
|
||||
$pluginService = app()->make(\Leantime\Core\Plugins\Plugins::class);
|
||||
$enabledPluginPaths = $pluginService->getEnabledPluginPaths();
|
||||
|
||||
foreach ($enabledPluginPaths as $pluginInfo) {
|
||||
$commandPath = $pluginInfo['path'].'/Command/';
|
||||
|
||||
if (is_dir($commandPath)) {
|
||||
|
||||
if ($pluginInfo['format'] == 'phar') {
|
||||
|
||||
include_once $pluginInfo['path'];
|
||||
$this->loadPhar($commandPath, $pluginInfo['foldername'].'.phar');
|
||||
}
|
||||
|
||||
$this->load($commandPath);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to scanning all plugin directories if service unavailable
|
||||
$ltPluginCommands = collect(glob(APP_ROOT.'/app/Plugins/**/Command/'));
|
||||
foreach ($ltPluginCommands as $pluginPath) {
|
||||
$this->load($pluginPath);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->commandRoutePaths as $path) {
|
||||
if (file_exists($path)) {
|
||||
require $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function call($command, array $parameters = [], $outputBuffer = null)
|
||||
{
|
||||
|
||||
if (array_key_exists('--domain', $parameters)) {
|
||||
$this->setDomain($parameters['--domain']);
|
||||
}
|
||||
|
||||
if (in_array($command, ['env:encrypt', 'env:decrypt'], true)) {
|
||||
$this->bootstrapWithoutBootingProviders();
|
||||
}
|
||||
|
||||
$this->bootstrap();
|
||||
|
||||
self::dispatch_event('console.bootstrapped', ['kernel' => $this, 'command' => $command]);
|
||||
|
||||
return $this->getArtisan()->call($command, $parameters, $outputBuffer);
|
||||
}
|
||||
|
||||
public function getArtisan()
|
||||
{
|
||||
|
||||
if (is_null($this->artisan)) {
|
||||
$this->artisan = (new LeantimeCli($this->app, $this->events, $this->app->version()))
|
||||
->resolveCommands($this->commands)
|
||||
->setContainerCommandLoader();
|
||||
|
||||
if ($this->symfonyDispatcher instanceof EventDispatcher) {
|
||||
$this->artisan->setDispatcher($this->symfonyDispatcher);
|
||||
$this->artisan->setSignalsToDispatchEvent();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->artisan;
|
||||
}
|
||||
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
// Set default timezone
|
||||
// config(['app.timezone' => config('defaultTimezone')]);
|
||||
|
||||
config(['schedule_timezone' => 'UTC']);
|
||||
|
||||
self::dispatch_event('cron', ['schedule' => $schedule], 'schedule');
|
||||
|
||||
}
|
||||
|
||||
public function setDomain(string $domain)
|
||||
{
|
||||
|
||||
if ($domain) {
|
||||
putenv('LEAN_APP_URL='.$domain);
|
||||
putenv('APP_URL='.$domain);
|
||||
|
||||
// When calling commands inside the app we can switch domains
|
||||
if (isset($this->app['config'])) {
|
||||
config(['app.url' => $domain]);
|
||||
config(['appUrl' => $domain]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected function loadPhar($paths, $pharName)
|
||||
{
|
||||
$paths = array_unique(Arr::wrap($paths));
|
||||
|
||||
$paths = array_filter($paths, function ($path) {
|
||||
return is_dir($path);
|
||||
});
|
||||
|
||||
if (empty($paths)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loadedPaths = array_values(
|
||||
array_unique(array_merge($this->loadedPaths, $paths))
|
||||
);
|
||||
|
||||
$namespace = $this->app->getNamespace();
|
||||
|
||||
foreach (Finder::create()->in($paths)->files() as $file) {
|
||||
|
||||
$command = $namespace.str_replace(
|
||||
['/', '.php', '\\'.$pharName],
|
||||
['\\', '', ''],
|
||||
Str::after($file->getPath().DIRECTORY_SEPARATOR.$file->getFilename(), realpath(app_path()).DIRECTORY_SEPARATOR)
|
||||
);
|
||||
|
||||
if (is_subclass_of($command, Command::class) &&
|
||||
! (new \ReflectionClass($command))->isAbstract()) {
|
||||
Artisan::starting(function ($artisan) use ($command) {
|
||||
$artisan->resolve($command);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/Core/Console/ConsoleSupportProvider.php
Normal file
20
app/Core/Console/ConsoleSupportProvider.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Console;
|
||||
|
||||
use Illuminate\Contracts\Support\DeferrableProvider;
|
||||
use Illuminate\Foundation\Providers\ComposerServiceProvider;
|
||||
use Illuminate\Foundation\Providers\ConsoleSupportServiceProvider;
|
||||
|
||||
class ConsoleSupportProvider extends ConsoleSupportServiceProvider implements DeferrableProvider
|
||||
{
|
||||
/**
|
||||
* The provider class names.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $providers = [
|
||||
CliServiceProvider::class,
|
||||
ComposerServiceProvider::class,
|
||||
];
|
||||
}
|
||||
63
app/Core/Controller/Composer.php
Normal file
63
app/Core/Controller/Composer.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Fluent;
|
||||
use Illuminate\View\View;
|
||||
|
||||
abstract class Composer
|
||||
{
|
||||
/**
|
||||
* List of views to receive data by this composer
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public static array $views;
|
||||
|
||||
/**
|
||||
* Current view
|
||||
*/
|
||||
protected View $view;
|
||||
|
||||
/**
|
||||
* Current view data
|
||||
*/
|
||||
protected Fluent $data;
|
||||
|
||||
/**
|
||||
* Compose the view before rendering.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function compose(View $view): void
|
||||
{
|
||||
$this->view = $view;
|
||||
$this->data = new Fluent($view->getData());
|
||||
|
||||
if (method_exists($this, 'init')) {
|
||||
app()->call([$this, 'init']);
|
||||
}
|
||||
|
||||
$view->with($this->merge());
|
||||
}
|
||||
|
||||
/**
|
||||
* Data to be merged and passed to the view before rendering.
|
||||
*/
|
||||
protected function merge(): array
|
||||
{
|
||||
return array_merge(
|
||||
$this->view->getData(),
|
||||
$this->with()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data to be passed to view before rendering
|
||||
*/
|
||||
protected function with(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
91
app/Core/Controller/Controller.php
Normal file
91
app/Core/Controller/Controller.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use BadMethodCallException;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Controller Class - Base class For all controllers
|
||||
*/
|
||||
abstract class Controller
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected Response $response;
|
||||
|
||||
/**
|
||||
* constructor - initialize private variables
|
||||
*
|
||||
*
|
||||
* @param IncomingRequest $incomingRequest The request to be initialized.
|
||||
* @param Template $tpl The template to be initialized.
|
||||
* @param Language $language The language to be initialized.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function __construct(
|
||||
/** @var IncomingRequest */
|
||||
protected IncomingRequest $incomingRequest,
|
||||
|
||||
/** @var Template */
|
||||
protected Template $tpl,
|
||||
|
||||
/** @var Language */
|
||||
protected Language $language,
|
||||
|
||||
) {
|
||||
self::dispatchEvent('begin');
|
||||
|
||||
// initialize
|
||||
if (method_exists($this, 'init')) {
|
||||
app()->call([$this, 'init']);
|
||||
}
|
||||
|
||||
self::dispatchEvent('end', $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* getResponse - returns the response
|
||||
*
|
||||
*
|
||||
* @return Response The response object.
|
||||
*/
|
||||
public function getResponse(): Response
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an action on the controller.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
public function callAction($method, $parameters)
|
||||
{
|
||||
return $this->{$method}($parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle calls to missing methods on the controller.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
throw new BadMethodCallException(sprintf(
|
||||
'Method %s::%s does not exist.', static::class, $method
|
||||
));
|
||||
}
|
||||
}
|
||||
471
app/Core/Controller/Frontcontroller.php
Normal file
471
app/Core/Controller/Frontcontroller.php
Normal file
@@ -0,0 +1,471 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\HtmxRequest;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Exception\RouteNotFoundException;
|
||||
|
||||
/**
|
||||
* Frontcontroller class
|
||||
*/
|
||||
class Frontcontroller
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
private IncomingRequest $incomingRequest;
|
||||
|
||||
protected $defaultRoute = 'dashboard.home';
|
||||
|
||||
protected Environment $config;
|
||||
|
||||
/**
|
||||
* __construct - Set the rootpath of the server
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(IncomingRequest $request, private PermissionEnforcer $permissionEnforcer)
|
||||
{
|
||||
$this->incomingRequest = $request;
|
||||
$this->config = app(Environment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* run - executes the action depending on Request or firstAction
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function dispatch(IncomingRequest $request): Response
|
||||
{
|
||||
$this->incomingRequest = $request;
|
||||
|
||||
[$moduleName, $controllerType, $controllerName, $method] = $this->parseRequestParts($request);
|
||||
|
||||
$this->dispatchEvent('execute_action_start', ['action' => $controllerName, 'module' => $moduleName]);
|
||||
|
||||
$routeParts = $this->getValidControllerCall($moduleName, $controllerName, $method, $controllerType);
|
||||
|
||||
// Setting default response code to 200, can be changed in controller
|
||||
$this->setResponseCode(200);
|
||||
|
||||
$this->dispatchEvent('execute_action_end', ['action' => $controllerName, 'module' => $moduleName]);
|
||||
|
||||
// execute action
|
||||
return $this->executeAction($routeParts['class'], $routeParts['method']);
|
||||
|
||||
}
|
||||
|
||||
public static function dispatch_request(IncomingRequest $request): Response
|
||||
{
|
||||
// Resolve through the container so constructor dependencies (e.g. PermissionEnforcer)
|
||||
// are injected; dispatch() sets the active request explicitly.
|
||||
$frontcontroller = app()->make(self::class);
|
||||
|
||||
return $frontcontroller->dispatch($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* parseRequestParts - Parses the request segments and sets the necessary values in the IncomingRequest object.
|
||||
*
|
||||
* @param IncomingRequest $request The incoming request object.
|
||||
* @return array An array containing the controller name, action name, and method.
|
||||
*/
|
||||
public function parseRequestParts(IncomingRequest $request)
|
||||
{
|
||||
|
||||
$id = null;
|
||||
|
||||
$segments = $request->segments();
|
||||
$method = strtolower($this->incomingRequest->getMethod());
|
||||
|
||||
if (count($segments) == 0) {
|
||||
$segments = explode('.', $this->defaultRoute);
|
||||
}
|
||||
|
||||
// First part is hx tells us this is a htmx controller request
|
||||
$controllerType = 'Controllers';
|
||||
if ($segments[0] == 'hx') {
|
||||
array_shift($segments);
|
||||
$controllerType = 'Hxcontrollers';
|
||||
}
|
||||
|
||||
// If only one segment part was given the url is mean to be an index placeholder
|
||||
if (count($segments) == 1) {
|
||||
$segments[] = 'index';
|
||||
}
|
||||
|
||||
// First segment is always module
|
||||
$moduleName = $segments[0] ?? '';
|
||||
|
||||
// Second is action
|
||||
$controllerName = $segments[1] ?? '';
|
||||
|
||||
// third is either id or method
|
||||
// we can say that a numeric value always represents an id
|
||||
if (isset($segments[2]) &&
|
||||
(is_numeric($segments[2]) || Str::isUuid($segments[2]))
|
||||
) {
|
||||
$id = $segments[2];
|
||||
}
|
||||
|
||||
// If not numeric, it's quite likely this is a method name
|
||||
// But it needs to be double checked.
|
||||
if (isset($segments[2]) &&
|
||||
! (is_numeric($segments[2]) || Str::isUuid($segments[2]))
|
||||
) {
|
||||
$method = $segments[2];
|
||||
}
|
||||
|
||||
// If a third segment is set it is the id
|
||||
if (isset($segments[3])) {
|
||||
$id = $segments[3];
|
||||
$method = $segments[2];
|
||||
$request_parts = implode('.', array_slice($segments, 3));
|
||||
$this->incomingRequest->query->set('request_parts', $request_parts);
|
||||
}
|
||||
|
||||
$this->incomingRequest->query->set('act', $moduleName.'.'.$controllerName.'.'.$method);
|
||||
$this->incomingRequest->setCurrentRoute($moduleName.'.'.$controllerName);
|
||||
|
||||
if ($id === '0' || ! empty($id)) {
|
||||
$this->incomingRequest->query->set('id', $id);
|
||||
}
|
||||
|
||||
// need to update all controllers to stop using global get and post methods.
|
||||
// In the meantime we are setting it again.
|
||||
$this->incomingRequest->overrideGlobals();
|
||||
|
||||
return [$moduleName, $controllerType, $controllerName, $method];
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* executeAction - includes the class in includes/modules by the Request
|
||||
*
|
||||
* @param string $controller actionname.filename
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function executeAction(string $controller, string $method): Response
|
||||
{
|
||||
|
||||
$parameters = $this->incomingRequest->getRequestParams();
|
||||
|
||||
// Enforce #[RequiresPermission] on the resolved action before instantiating the
|
||||
// controller. This is the single chokepoint for every convention-routed controller,
|
||||
// regardless of which base class (if any) it extends.
|
||||
$this->permissionEnforcer->enforce($controller, $method, $parameters);
|
||||
|
||||
$controllerClass = app()->make($controller);
|
||||
|
||||
$response = $controllerClass->callAction($method, $parameters);
|
||||
|
||||
// A controller may return a Symfony Response directly, a Responsable (e.g. an
|
||||
// ImageResponse / JsonRpcResponse — now honored on this legacy dispatch path the same
|
||||
// way Laravel's router and the ExceptionHandler already do), or a string fragment key
|
||||
// handled by the controller's own getResponse().
|
||||
return match (true) {
|
||||
$response instanceof Response => $response,
|
||||
$response instanceof Responsable => $response->toResponse($this->incomingRequest),
|
||||
default => $controllerClass->getResponse($response),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the type of controller based on the incoming request.
|
||||
*
|
||||
* @return string The type of controller. Possible values are 'Controllers' or 'Hxcontrollers'.
|
||||
*/
|
||||
protected function getControllerType(): string
|
||||
{
|
||||
|
||||
$controllerType = 'Controllers';
|
||||
if (
|
||||
($this->incomingRequest instanceof HtmxRequest) &&
|
||||
$this->incomingRequest->header('is-modal') == false &&
|
||||
$this->incomingRequest->header('hx-boosted') == false
|
||||
) {
|
||||
$controllerType = 'Hxcontrollers';
|
||||
}
|
||||
|
||||
return $controllerType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the valid controller call based on the module name, action name, and method name.
|
||||
*
|
||||
* @param string $moduleName The name of the module.
|
||||
* @param string $actionName The name of the action.
|
||||
* @param string $methodName The name of the method.
|
||||
* @return array The valid controller call in the form of an associative array. The "class" key represents the class path of the controller,
|
||||
* and the "method" key represents the method name of the controller.
|
||||
*/
|
||||
public function getValidControllerCall(string $moduleName, string $actionName, string $methodName, string $controllerType): array
|
||||
{
|
||||
|
||||
$moduleName = Str::studly($moduleName);
|
||||
$actionName = Str::studly($actionName);
|
||||
$methodNameLower = Str::lower($methodName);
|
||||
$routepath = $moduleName.'.'.$controllerType.'.'.$actionName;
|
||||
$actionPath = $moduleName.'\\'.$controllerType.'\\'.$actionName;
|
||||
|
||||
if ($this->config->debug == false) {
|
||||
$cachedRoute = Cache::store('installation')->get('routes.'.$routepath.'.'.$methodNameLower);
|
||||
|
||||
// Cached routes can outlive a deploy (e.g. a controller's run() replaced by get()/post()).
|
||||
// Only trust the cache if the class and method still exist; otherwise drop it and re-resolve.
|
||||
if (
|
||||
is_array($cachedRoute)
|
||||
&& isset($cachedRoute['class'], $cachedRoute['method'])
|
||||
&& class_exists($cachedRoute['class'])
|
||||
&& method_exists($cachedRoute['class'], $cachedRoute['method'])
|
||||
) {
|
||||
return $cachedRoute;
|
||||
}
|
||||
|
||||
if ($cachedRoute !== null) {
|
||||
Cache::store('installation')->forget('routes.'.$routepath.'.'.$methodNameLower);
|
||||
}
|
||||
}
|
||||
|
||||
$classPath = $this->getClassPath($controllerType, $moduleName, $actionName);
|
||||
|
||||
if ($classPath === false) {
|
||||
throw new NotFoundHttpException("Can't find a valid controller for ".strip_tags($moduleName).'/'.strip_tags($actionName));
|
||||
}
|
||||
|
||||
$classMethod = $this->getValidControllerMethod($classPath, $methodName);
|
||||
|
||||
Cache::store('installation')->set('routes.'.$routepath.'.'.($classMethod == 'run' ? $methodNameLower : $classMethod), ['class' => $classPath, 'method' => $classMethod]);
|
||||
|
||||
return ['class' => $classPath, 'method' => $classMethod];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the class path of a controller based on the provided controller type, module name, and action name.
|
||||
*
|
||||
* @param string $controllerType The type of controller. Possible values are 'Controllers' or 'Hxcontrollers'.
|
||||
**/
|
||||
public function getClassPath(string $controllerType, string $moduleName, string $actionName): string|false
|
||||
{
|
||||
|
||||
$controllerNs = 'Domain';
|
||||
$classname = 'Leantime\\Domain\\'.$moduleName.'\\'.$controllerType.'\\'.$actionName;
|
||||
|
||||
if (class_exists($classname)) {
|
||||
return $classname;
|
||||
}
|
||||
|
||||
// Check if hxcontroller exists
|
||||
$classname = 'Leantime\\Domain\\'.$moduleName.'\\Hxcontrollers\\'.$actionName;
|
||||
|
||||
if (class_exists($classname)) {
|
||||
return $classname;
|
||||
}
|
||||
|
||||
$classname = 'Leantime\\Plugins\\'.$moduleName.'\\'.$controllerType.'\\'.$actionName;
|
||||
|
||||
$enabledPlugins = app()->make(\Leantime\Domain\Plugins\Services\Plugins::class)->getEnabledPlugins();
|
||||
|
||||
$pluginEnabled = false;
|
||||
foreach ($enabledPlugins as $key => $obj) {
|
||||
if (strtolower($obj->foldername) !== strtolower($moduleName)) {
|
||||
continue;
|
||||
}
|
||||
$pluginEnabled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $pluginEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (class_exists($classname)) {
|
||||
return $classname;
|
||||
}
|
||||
|
||||
$classname = 'Leantime\\Plugins\\'.$moduleName.'\\Hxcontrollers\\'.$actionName;
|
||||
if (class_exists($classname)) {
|
||||
return $classname;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a valid controller method based on the given controller class and method.
|
||||
*
|
||||
* @param string $controllerClass The fully qualified class name of the controller.
|
||||
* @param string $method The method name to check for validity.
|
||||
* @return string The valid controller method name. If the given method is "head",
|
||||
* it will be converted to "get". If the given method exists in the controller
|
||||
* class, it will be returned. Otherwise, if the "run" method exists in the
|
||||
* controller class, it will be returned. If no valid method is found, a
|
||||
* RouteNotFoundException will be thrown.
|
||||
*
|
||||
* @throws RouteNotFoundException If no valid method is found for the given route.
|
||||
*/
|
||||
public function getValidControllerMethod(string $controllerClass, string $method): string
|
||||
{
|
||||
$methodFormatted = Str::camel($method);
|
||||
$httpMethod = Str::lower($this->incomingRequest->getMethod());
|
||||
|
||||
if (Str::lower($method) == 'head') {
|
||||
$method = 'get';
|
||||
}
|
||||
|
||||
// First check if the given method exists.
|
||||
if (method_exists($controllerClass, $methodFormatted)) {
|
||||
|
||||
return $methodFormatted;
|
||||
// Then check if the http method exists as verb
|
||||
} elseif (method_exists($controllerClass, $httpMethod)) {
|
||||
|
||||
// If this was the case our first assumption around $method was wrong and $method is actually a
|
||||
// id/slug. Let's set id to that slug.
|
||||
$this->incomingRequest->query->set('id', $method);
|
||||
|
||||
return $httpMethod;
|
||||
// Just for backwards compatibility, let's also check if run exists.
|
||||
} elseif (method_exists($controllerClass, 'run')) {
|
||||
return 'run';
|
||||
}
|
||||
|
||||
throw new NotFoundHttpException("Can't find valid method for ".strip_tags($method).' in '.strip_tags($controllerClass));
|
||||
}
|
||||
|
||||
/**
|
||||
* getActionName - split string to get actionName
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function getActionName(?string $completeName = null): string
|
||||
{
|
||||
$completeName ??= currentRoute();
|
||||
$actionParts = explode('.', empty($completeName) ? currentRoute() : $completeName);
|
||||
|
||||
// If not action name was given, call index controller
|
||||
if (is_array($actionParts) && count($actionParts) == 1) {
|
||||
return 'index';
|
||||
} elseif (is_array($actionParts) && count($actionParts) >= 2) {
|
||||
return $actionParts[1];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the method name based on the complete name of a route.
|
||||
*
|
||||
* @param string|null $completeName The complete name of the route. Defaults to the current route if not provided.
|
||||
* @return string The method name. If the route name consists of two parts (e.g. "controllers.index"), the method name will be the lowercase representation of the current request method
|
||||
*. If the route name consists of three parts (e.g. "controllers.update"), the method name will be the second part of the route name. Otherwise, an empty string is returned.
|
||||
*
|
||||
* @deprecated
|
||||
**/
|
||||
public static function getMethodName(?string $completeName = null): string
|
||||
{
|
||||
$completeName ??= currentRoute();
|
||||
$actionParts = explode('.', empty($completeName) ? currentRoute() : $completeName);
|
||||
|
||||
// If not action name was given, call index controller
|
||||
if (is_array($actionParts) && count($actionParts) == 2) {
|
||||
return strtolower(app('request')->getMethod());
|
||||
} elseif (is_array($actionParts) && count($actionParts) == 3) {
|
||||
return $actionParts[2];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* getModuleName - split string to get modulename
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function getModuleName(?string $completeName = null): string
|
||||
{
|
||||
$completeName ??= currentRoute();
|
||||
$actionParts = explode('.', empty($completeName) ? currentRoute() : $completeName);
|
||||
|
||||
if (is_array($actionParts)) {
|
||||
return $actionParts[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect - redirects to a given url
|
||||
*/
|
||||
public static function redirect(string $url, int $http_response_code = 303, $headers = []): RedirectResponse
|
||||
{
|
||||
|
||||
if (app('request')->headers->get('is-modal')) {
|
||||
Frontcontroller::redirectHtmx($url, $headers);
|
||||
}
|
||||
|
||||
return new RedirectResponse(
|
||||
trim(preg_replace('/\s\s+/', '', strip_tags($url))),
|
||||
$http_response_code,
|
||||
$headers
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect - redirects an htmx page.
|
||||
*
|
||||
* @param array $headers
|
||||
*/
|
||||
public static function redirectHtmx(string $url, $headers = []): Response
|
||||
{
|
||||
// modal redirect
|
||||
if (Str::start($url, '#')) {
|
||||
$hxCurrentUrl = app('request')->headers->get('hx-current-url');
|
||||
$mainPageUrl = Str::before($hxCurrentUrl, '#');
|
||||
$url = $mainPageUrl.''.$url;
|
||||
}
|
||||
|
||||
$headers['HX-Redirect'] = $url;
|
||||
|
||||
// $headers["hx-push-url"] = $url;
|
||||
// $headers["hx-replace-url"] = $url;
|
||||
// $headers["HX-Refresh"] = true;
|
||||
|
||||
// this redirect is actually handled on the client side.
|
||||
// We'll just return an empty response with a few headers
|
||||
return new Response(
|
||||
'redirecting...',
|
||||
200, // Anything else than 200 will fail.
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* getCurrentRoute - gets current route
|
||||
*
|
||||
* @deprecated use request class to get current route
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function getCurrentRoute()
|
||||
{
|
||||
return app('request')->getCurrentRoute();
|
||||
}
|
||||
|
||||
/**
|
||||
* setResponseCode - sets the response code
|
||||
*/
|
||||
public function setResponseCode(int $responseCode): void
|
||||
{
|
||||
http_response_code($responseCode);
|
||||
}
|
||||
}
|
||||
151
app/Core/Controller/HtmxController.php
Normal file
151
app/Core/Controller/HtmxController.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use BadMethodCallException;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Events\Htmx\HtmxEvent;
|
||||
use Leantime\Core\Events\Htmx\HtmxEvents;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use LogicException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* HtmxController Class - Base class For all htmx controllers
|
||||
*
|
||||
* @method string|null run() The fallback method to be initialized.
|
||||
*/
|
||||
abstract class HtmxController
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected Response $response;
|
||||
|
||||
protected static string $view;
|
||||
|
||||
protected array $headers = [];
|
||||
|
||||
/**
|
||||
* constructor - initialize private variables
|
||||
*
|
||||
* @param IncomingRequest $incomingRequest The request to be initialized.
|
||||
* @param Template $tpl The template to be initialized.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function __construct(
|
||||
/** @var IncomingRequest $incomingRequest */
|
||||
protected IncomingRequest $incomingRequest,
|
||||
|
||||
/** @var Template $tpl */
|
||||
public Template $tpl,
|
||||
|
||||
/** @var Template $tpl */
|
||||
public Language $language,
|
||||
|
||||
) {
|
||||
self::dispatchEvent('begin');
|
||||
|
||||
$this->incomingRequest = $incomingRequest;
|
||||
$this->tpl = $tpl;
|
||||
$this->response = app()->make(Response::class);
|
||||
|
||||
// initialize
|
||||
if (method_exists($this, 'init')) {
|
||||
app()->call([$this, 'init']);
|
||||
}
|
||||
|
||||
if (! property_exists($this, 'view')) {
|
||||
throw new LogicException('HTMX Controllers must include the "$view" static property');
|
||||
}
|
||||
|
||||
self::dispatchEvent('end', $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the response header to trigger an htmx event
|
||||
*
|
||||
**/
|
||||
public function setHTMXEvent(HtmxEvent|string $eventName): void
|
||||
{
|
||||
$this->headers['HX-Trigger'] ??= [];
|
||||
$this->headers['HX-Trigger'][] = $eventName instanceof HtmxEvent ? $eventName->event() : $eventName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one or more client (HTMX) events on the HX-Trigger response header.
|
||||
* Accepts HtmxEvent enum cases (preferred) or raw strings.
|
||||
*/
|
||||
public function emit(HtmxEvent|string ...$events): void
|
||||
{
|
||||
foreach ($events as $event) {
|
||||
$this->setHTMXEvent($event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the response
|
||||
*
|
||||
**/
|
||||
public function getResponse($fragment): Response
|
||||
{
|
||||
$this->response = tap(
|
||||
$this->tpl->displayFragment($this::$view, $fragment ?? ''),
|
||||
function (Response $response): void {
|
||||
// Merge queued HX-Trigger events from BOTH the controller and the template
|
||||
// (set()ing each bag separately would let the second silently overwrite the
|
||||
// first), expand legacy aliases, and emit the comma-separated list once.
|
||||
$triggerEvents = array_merge(
|
||||
$this->headers['HX-Trigger'] ?? [],
|
||||
(array) ($this->tpl->getHeaders()['HX-Trigger'] ?? [])
|
||||
);
|
||||
|
||||
foreach ([$this->headers, $this->tpl->getHeaders()] as $headerBag) {
|
||||
foreach ($headerBag as $key => $value) {
|
||||
if ($key === 'HX-Trigger') {
|
||||
continue;
|
||||
}
|
||||
$response->headers->set($key, is_array($value) ? implode(',', $value) : $value);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($triggerEvents)) {
|
||||
$response->headers->set('HX-Trigger', HtmxEvents::triggerHeader($triggerEvents));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return $this->response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an action on the controller.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
public function callAction($method, $parameters)
|
||||
{
|
||||
return $this->{$method}($parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle calls to missing methods on the controller.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
throw new BadMethodCallException(sprintf(
|
||||
'Method %s::%s does not exist.', static::class, $method
|
||||
));
|
||||
}
|
||||
}
|
||||
55
app/Core/Controller/HxComponent.php
Normal file
55
app/Core/Controller/HxComponent.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use Leantime\Core\Events\Htmx\HtmxEvent;
|
||||
|
||||
/**
|
||||
* Base class for HTMX-backed components.
|
||||
*
|
||||
* An HxComponent is an {@see HtmxController} that also declares its event contract — the route it
|
||||
* is fetched from, the events that should make it re-fetch ({@see listensTo}), and the events it
|
||||
* emits when its actions mutate data ({@see emits}). The `<x-global::hx :for="...::class">` mount
|
||||
* component reads this contract to auto-wire `hx-get`/`hx-trigger`, so the emit side and the listen
|
||||
* side reference the SAME enum case and can never drift apart (the class of bug where a template
|
||||
* listens for `subtasksUpdated` while the controller emits `subtasks_update`).
|
||||
*
|
||||
* Plain {@see HtmxController}s remain valid; declaring the contract is opt-in. Components that don't
|
||||
* extend this can still be mounted with explicit `endpoint`/`listen` attributes on `<x-global::hx>`.
|
||||
*
|
||||
* @method string|null run() Inherited fallback action.
|
||||
*/
|
||||
abstract class HxComponent extends HtmxController
|
||||
{
|
||||
/** The action invoked when the component is first mounted (its "render me" endpoint). */
|
||||
public static string $mountAction = 'get';
|
||||
|
||||
/** Default `hx-swap` strategy for the mount wrapper. */
|
||||
public static string $swap = 'outerHTML';
|
||||
|
||||
/**
|
||||
* The hx route segment, e.g. "tickets/timerButton" → /hx/tickets/timerButton/{action}.
|
||||
*/
|
||||
abstract public static function route(): string;
|
||||
|
||||
/**
|
||||
* Events that should cause this component to re-fetch itself.
|
||||
*
|
||||
* @return array<int, HtmxEvent>
|
||||
*/
|
||||
public static function listensTo(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Events this component emits when its actions mutate data (contract/documentation; the actual
|
||||
* emission happens via {@see \Leantime\Core\UI\Template::emit()} inside the action methods).
|
||||
*
|
||||
* @return array<int, HtmxEvent>
|
||||
*/
|
||||
public static function emits(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
42
app/Core/Database/DatabaseManager.php
Normal file
42
app/Core/Database/DatabaseManager.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Database;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DatabaseManager
|
||||
{
|
||||
/**
|
||||
* Switch the database connection to use the given configuration.
|
||||
*
|
||||
* @param array $config Database configuration with keys: dbHost, dbDatabase, dbUser, dbPassword
|
||||
*/
|
||||
public static function switchConnection(array $config): void
|
||||
{
|
||||
try {
|
||||
$connectionName = config('database.default', 'mysql');
|
||||
|
||||
// Purge existing connections
|
||||
DB::purge($connectionName);
|
||||
|
||||
// Update the configuration
|
||||
config([
|
||||
"database.connections.{$connectionName}.host" => $config['dbHost'],
|
||||
"database.connections.{$connectionName}.database" => $config['dbDatabase'],
|
||||
"database.connections.{$connectionName}.username" => $config['dbUser'],
|
||||
"database.connections.{$connectionName}.password" => $config['dbPassword'],
|
||||
]);
|
||||
|
||||
// Reconnect with new configuration
|
||||
DB::reconnect($connectionName);
|
||||
|
||||
// Verify connection
|
||||
DB::connection($connectionName)->getPdo();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Database connection failed: '.$e->getMessage());
|
||||
throw new \RuntimeException('Failed to establish database connection: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/Core/Database/DatabaseServiceProvider.php
Normal file
31
app/Core/Database/DatabaseServiceProvider.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Database;
|
||||
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\DatabaseServiceProvider as LaravelDatabaseServiceProvider;
|
||||
|
||||
class DatabaseServiceProvider extends LaravelDatabaseServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
// Register Laravel's database service first
|
||||
parent::register();
|
||||
|
||||
// Register custom PostgreSQL connection that handles empty-string-to-null conversion.
|
||||
// MySQL silently coerces '' to NULL/zero for non-string columns; PostgreSQL does not.
|
||||
Connection::resolverFor('pgsql', function ($connection, $database, $prefix, $config) {
|
||||
return new LtPostgresConnection($connection, $database, $prefix, $config);
|
||||
});
|
||||
|
||||
// Register Db as a singleton with proper dependency injection
|
||||
app()->singleton(\Leantime\Core\Db\Db::class, function ($app) {
|
||||
return new \Leantime\Core\Db\Db($app);
|
||||
});
|
||||
}
|
||||
}
|
||||
41
app/Core/Database/LtPostgresConnection.php
Normal file
41
app/Core/Database/LtPostgresConnection.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Database;
|
||||
|
||||
use Illuminate\Database\PostgresConnection;
|
||||
|
||||
/**
|
||||
* Custom PostgreSQL connection that handles MySQL-isms in the Leantime codebase.
|
||||
*
|
||||
* MySQL silently coerces empty strings to appropriate zero/null values for
|
||||
* non-string column types (datetime, integer, float). PostgreSQL does not.
|
||||
* This connection class converts empty string bindings to null so that
|
||||
* existing repository code works without modification on PostgreSQL.
|
||||
*
|
||||
* String columns that legitimately need empty strings should be defined as
|
||||
* nullable() in the SchemaBuilder so they accept null gracefully.
|
||||
*/
|
||||
class LtPostgresConnection extends PostgresConnection
|
||||
{
|
||||
/**
|
||||
* Prepare the query bindings for execution.
|
||||
*
|
||||
* Converts empty strings to null for PostgreSQL compatibility.
|
||||
* MySQL treats '' as NULL/zero for datetime, int, and float columns,
|
||||
* but PostgreSQL rejects them with type errors.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function prepareBindings(array $bindings)
|
||||
{
|
||||
$bindings = parent::prepareBindings($bindings);
|
||||
|
||||
foreach ($bindings as $key => $value) {
|
||||
if ($value === '') {
|
||||
$bindings[$key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $bindings;
|
||||
}
|
||||
}
|
||||
413
app/Core/Db/DatabaseHelper.php
Normal file
413
app/Core/Db/DatabaseHelper.php
Normal file
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Db;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/**
|
||||
* DatabaseHelper provides cross-database compatibility for common SQL functions
|
||||
*
|
||||
* This helper abstracts database-specific SQL syntax to support MySQL, PostgreSQL, and MS SQL Server.
|
||||
* It handles functions like GROUP_CONCAT, WEEK(), date functions, and other database-specific operations.
|
||||
*/
|
||||
class DatabaseHelper
|
||||
{
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param ConnectionInterface $db The database connection
|
||||
*/
|
||||
public function __construct(private ConnectionInterface $db) {}
|
||||
|
||||
/**
|
||||
* Generate cross-database string aggregation SQL
|
||||
*
|
||||
* Generates the appropriate SQL for concatenating strings from multiple rows:
|
||||
* - MySQL: GROUP_CONCAT(column SEPARATOR ',')
|
||||
* - PostgreSQL: STRING_AGG(CAST(column AS TEXT), ',')
|
||||
* - MS SQL: STRING_AGG(CAST(column AS NVARCHAR(MAX)), ',')
|
||||
*
|
||||
* @param string $column The column name to aggregate
|
||||
* @param string $separator The separator to use between values (default: ',')
|
||||
* @return string The database-specific SQL string
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function stringAggregate(string $column, string $separator = ','): string
|
||||
{
|
||||
return match ($this->db->getDriverName()) {
|
||||
'mysql' => "GROUP_CONCAT({$column} SEPARATOR '{$separator}')",
|
||||
'pgsql' => "STRING_AGG(CAST({$column} AS TEXT), '{$separator}')",
|
||||
'sqlsrv' => "STRING_AGG(CAST({$column} AS NVARCHAR(MAX)), '{$separator}')",
|
||||
default => "GROUP_CONCAT({$column} SEPARATOR '{$separator}')", // fallback to MySQL syntax
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database week number extraction SQL
|
||||
*
|
||||
* Generates the appropriate SQL for extracting the week number from a date:
|
||||
* - MySQL: WEEK(column)
|
||||
* - PostgreSQL: EXTRACT(WEEK FROM column)::integer
|
||||
* - MS SQL: DATEPART(week, column)
|
||||
*
|
||||
* @param string $column The column name containing the date
|
||||
* @return string The database-specific SQL string
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function weekNumber(string $column): string
|
||||
{
|
||||
return match ($this->db->getDriverName()) {
|
||||
'mysql' => "WEEK({$column})",
|
||||
'pgsql' => "EXTRACT(WEEK FROM {$column})::integer",
|
||||
'sqlsrv' => "DATEPART(week, {$column})",
|
||||
default => "WEEK({$column})", // fallback to MySQL syntax
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse status group SQL strings to arrays
|
||||
*
|
||||
* Converts status group SQL strings like 'IN(0,-1,3)' to integer arrays [0, -1, 3]
|
||||
* This is used to convert legacy SQL-based status groups to Query Builder compatible arrays.
|
||||
*
|
||||
* @param array $statusGroupsSQL Associative array with status group names as keys and SQL strings as values
|
||||
* @return array Associative array with status group names as keys and integer arrays as values
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function parseStatusGroups(array $statusGroupsSQL): array
|
||||
{
|
||||
$statusGroups = [];
|
||||
|
||||
foreach ($statusGroupsSQL as $key => $sqlString) {
|
||||
// Match patterns like "IN(0,-1,3)" or "IN (0, -1, 3)"
|
||||
if (preg_match('/IN\s*\(([\d,\s-]+)\)/', $sqlString, $matches)) {
|
||||
// Split by comma, trim whitespace, convert to integers
|
||||
$values = explode(',', $matches[1]);
|
||||
$statusGroups[$key] = array_map(fn ($val) => (int) trim($val), $values);
|
||||
} else {
|
||||
// If pattern doesn't match, return empty array
|
||||
$statusGroups[$key] = [];
|
||||
}
|
||||
}
|
||||
|
||||
return $statusGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database date formatting SQL
|
||||
*
|
||||
* Generates the appropriate SQL for formatting dates:
|
||||
* - MySQL: DATE_FORMAT(column, format)
|
||||
* - PostgreSQL: TO_CHAR(column, format)
|
||||
* - MS SQL: FORMAT(column, format)
|
||||
*
|
||||
* Note: The format string is converted from MySQL format to PostgreSQL/MS SQL format when needed
|
||||
*
|
||||
* @param string $column The column name containing the date
|
||||
* @param string $format The format string (MySQL DATE_FORMAT syntax)
|
||||
* @return string The database-specific SQL string
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function formatDate(string $column, string $format): string
|
||||
{
|
||||
return match ($this->db->getDriverName()) {
|
||||
'mysql' => "DATE_FORMAT({$column}, '{$format}')",
|
||||
'pgsql' => "TO_CHAR({$column}, '{$this->convertDateFormatToPostgres($format)}')",
|
||||
'sqlsrv' => "FORMAT({$column}, '{$this->convertDateFormatToMsSql($format)}')",
|
||||
default => "DATE_FORMAT({$column}, '{$format}')", // fallback to MySQL syntax
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MySQL DATE_FORMAT format string to PostgreSQL TO_CHAR format
|
||||
*
|
||||
* @param string $mysqlFormat MySQL format string
|
||||
* @return string PostgreSQL format string
|
||||
*/
|
||||
private function convertDateFormatToPostgres(string $mysqlFormat): string
|
||||
{
|
||||
// Common MySQL to PostgreSQL format conversions
|
||||
$conversions = [
|
||||
'%Y' => 'YYYY', // 4-digit year
|
||||
'%y' => 'YY', // 2-digit year
|
||||
'%m' => 'MM', // Month number (01-12)
|
||||
'%d' => 'DD', // Day of month (01-31)
|
||||
'%e' => 'FMDD', // Day of month (1-31) without leading zero
|
||||
'%H' => 'HH24', // Hour (00-23)
|
||||
'%i' => 'MI', // Minutes (00-59)
|
||||
'%s' => 'SS', // Seconds (00-59)
|
||||
'%W' => 'Day', // Weekday name
|
||||
'%M' => 'Month', // Month name
|
||||
];
|
||||
|
||||
return str_replace(array_keys($conversions), array_values($conversions), $mysqlFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MySQL DATE_FORMAT format string to MS SQL FORMAT format
|
||||
*
|
||||
* @param string $mysqlFormat MySQL format string
|
||||
* @return string MS SQL format string
|
||||
*/
|
||||
private function convertDateFormatToMsSql(string $mysqlFormat): string
|
||||
{
|
||||
// Common MySQL to MS SQL format conversions
|
||||
$conversions = [
|
||||
'%Y' => 'yyyy', // 4-digit year
|
||||
'%y' => 'yy', // 2-digit year
|
||||
'%m' => 'MM', // Month number (01-12)
|
||||
'%d' => 'dd', // Day of month (01-31)
|
||||
'%e' => 'd', // Day of month (1-31) without leading zero
|
||||
'%H' => 'HH', // Hour (00-23)
|
||||
'%i' => 'mm', // Minutes (00-59)
|
||||
'%s' => 'ss', // Seconds (00-59)
|
||||
'%W' => 'dddd', // Weekday name
|
||||
'%M' => 'MMMM', // Month name
|
||||
];
|
||||
|
||||
return str_replace(array_keys($conversions), array_values($conversions), $mysqlFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database SQL for yesterday's date
|
||||
*
|
||||
* Generates the appropriate SQL for getting yesterday's date:
|
||||
* - MySQL: DATE(NOW() - INTERVAL 1 DAY)
|
||||
* - PostgreSQL: (CURRENT_DATE - INTERVAL '1 day')::date
|
||||
* - MS SQL: CAST(DATEADD(day, -1, GETDATE()) AS DATE)
|
||||
*
|
||||
* @return string The database-specific SQL string
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function yesterdayDate(): string
|
||||
{
|
||||
return match ($this->db->getDriverName()) {
|
||||
'mysql' => 'DATE(NOW() - INTERVAL 1 DAY)',
|
||||
'pgsql' => "(CURRENT_DATE - INTERVAL '1 day')::date",
|
||||
'sqlsrv' => 'CAST(DATEADD(day, -1, GETDATE()) AS DATE)',
|
||||
default => 'DATE(NOW() - INTERVAL 1 DAY)', // fallback to MySQL syntax
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database SQL for the current date
|
||||
*
|
||||
* Generates the appropriate SQL for getting the current date:
|
||||
* - MySQL: CURDATE()
|
||||
* - PostgreSQL: CURRENT_DATE
|
||||
* - MS SQL: CAST(GETDATE() AS DATE)
|
||||
*
|
||||
* @return string The database-specific SQL string
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function currentDate(): string
|
||||
{
|
||||
return match ($this->db->getDriverName()) {
|
||||
'mysql' => 'CURDATE()',
|
||||
'pgsql' => 'CURRENT_DATE',
|
||||
'sqlsrv' => 'CAST(GETDATE() AS DATE)',
|
||||
default => 'CURDATE()', // fallback to MySQL syntax
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database SQL for date comparison with yesterday
|
||||
*
|
||||
* Generates the appropriate SQL for comparing a date column with yesterday's date:
|
||||
* - MySQL: DATE(column) = DATE(NOW() - INTERVAL 1 DAY)
|
||||
* - PostgreSQL: column::date = (CURRENT_DATE - INTERVAL '1 day')::date
|
||||
* - MS SQL: CAST(column AS DATE) = CAST(DATEADD(day, -1, GETDATE()) AS DATE)
|
||||
*
|
||||
* @param string $column The column name containing the date
|
||||
* @return string The database-specific SQL string
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function isYesterday(string $column): string
|
||||
{
|
||||
return match ($this->db->getDriverName()) {
|
||||
'mysql' => "DATE({$column}) = DATE(NOW() - INTERVAL 1 DAY)",
|
||||
'pgsql' => "{$column}::date = (CURRENT_DATE - INTERVAL '1 day')::date",
|
||||
'sqlsrv' => "CAST({$column} AS DATE) = CAST(DATEADD(day, -1, GETDATE()) AS DATE)",
|
||||
default => "DATE({$column}) = DATE(NOW() - INTERVAL 1 DAY)", // fallback to MySQL syntax
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current database driver name
|
||||
*
|
||||
* @return string The driver name ('mysql', 'pgsql', 'sqlsrv', etc.)
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getDriverName(): string
|
||||
{
|
||||
return $this->db->getDriverName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database SQL for FIND_IN_SET functionality
|
||||
*
|
||||
* Searches for a value in a comma-separated string field:
|
||||
* - MySQL: FIND_IN_SET(needle, haystack)
|
||||
* - PostgreSQL: needle = ANY(STRING_TO_ARRAY(haystack, ','))
|
||||
* - MS SQL: CHARINDEX(',' + needle + ',', ',' + haystack + ',') > 0
|
||||
*
|
||||
* @param string $needle The value to search for (use '?' for parameter binding)
|
||||
* @param string $haystack The column containing comma-separated values
|
||||
* @return string The database-specific SQL string
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function findInSet(string $needle, string $haystack): string
|
||||
{
|
||||
return match ($this->db->getDriverName()) {
|
||||
'mysql' => "FIND_IN_SET({$needle}, {$haystack})",
|
||||
'pgsql' => "{$needle} = ANY(STRING_TO_ARRAY({$haystack}, ','))",
|
||||
'sqlsrv' => "CHARINDEX(',' + CAST({$needle} AS NVARCHAR) + ',', ',' + {$haystack} + ',') > 0",
|
||||
default => "FIND_IN_SET({$needle}, {$haystack})", // fallback to MySQL syntax
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database SQL for current timestamp
|
||||
*
|
||||
* Generates the appropriate SQL for the current date and time:
|
||||
* - MySQL: NOW()
|
||||
* - PostgreSQL: CURRENT_TIMESTAMP
|
||||
* - MS SQL: GETDATE()
|
||||
*
|
||||
* @return string The database-specific SQL string
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function currentTimestamp(): string
|
||||
{
|
||||
return match ($this->db->getDriverName()) {
|
||||
'mysql' => 'NOW()',
|
||||
'pgsql' => 'CURRENT_TIMESTAMP',
|
||||
'sqlsrv' => 'GETDATE()',
|
||||
default => 'NOW()', // fallback to MySQL syntax
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database SQL for IFNULL/COALESCE functionality
|
||||
*
|
||||
* Returns the first non-null value:
|
||||
* - MySQL: IFNULL(expr, default)
|
||||
* - PostgreSQL: COALESCE(expr, default)
|
||||
* - MS SQL: COALESCE(expr, default)
|
||||
*
|
||||
* Note: COALESCE is ANSI SQL standard and works on all databases,
|
||||
* but this method is provided for explicit IFNULL replacement.
|
||||
*
|
||||
* @param string $expr The expression to check for null
|
||||
* @param string $default The default value if expr is null
|
||||
* @return string The database-specific SQL string
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function ifNull(string $expr, string $default): string
|
||||
{
|
||||
return match ($this->db->getDriverName()) {
|
||||
'mysql' => "IFNULL({$expr}, {$default})",
|
||||
'pgsql', 'sqlsrv' => "COALESCE({$expr}, {$default})",
|
||||
default => "IFNULL({$expr}, {$default})", // fallback to MySQL syntax
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database SQL for IF/CASE functionality
|
||||
*
|
||||
* Replaces MySQL's IF(condition, then, else) with standard CASE WHEN:
|
||||
* - MySQL: IF(condition, then, else)
|
||||
* - PostgreSQL/MS SQL: CASE WHEN condition THEN then ELSE else END
|
||||
*
|
||||
* Note: This method always returns CASE WHEN syntax which is ANSI SQL standard
|
||||
* and works on all databases. Use this for cross-database compatibility.
|
||||
*
|
||||
* @param string $condition The condition to evaluate
|
||||
* @param string $then The value if condition is true
|
||||
* @param string $else The value if condition is false
|
||||
* @return string The CASE WHEN SQL string (works on all databases)
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function ifThen(string $condition, string $then, string $else): string
|
||||
{
|
||||
// CASE WHEN is ANSI SQL standard and works on all databases
|
||||
return "CASE WHEN {$condition} THEN {$then} ELSE {$else} END";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a column or alias identifier with the correct quoting for the current database
|
||||
*
|
||||
* Uses the connection grammar to produce the correct identifier quoting:
|
||||
* - MySQL: backticks (`identifier`)
|
||||
* - PostgreSQL: double quotes ("identifier")
|
||||
* - MS SQL: square brackets ([identifier])
|
||||
*
|
||||
* Supports dotted notation for table-qualified columns (e.g. 'table.column').
|
||||
*
|
||||
* @param string $identifier The column, alias, or table.column identifier to wrap
|
||||
* @return string The properly quoted identifier
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function wrapColumn(string $identifier): string
|
||||
{
|
||||
return $this->db->getQueryGrammar()->wrap($identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cross-database CAST expression
|
||||
*
|
||||
* Maps abstract type names to database-specific CAST target types:
|
||||
* - 'text': MySQL -> CHAR, PostgreSQL -> TEXT, MS SQL -> NVARCHAR(MAX)
|
||||
* - 'integer': MySQL -> SIGNED, PostgreSQL -> INTEGER, MS SQL -> INT
|
||||
* - 'decimal': MySQL -> DECIMAL(precision,scale), PostgreSQL/MS SQL -> NUMERIC(precision,scale)
|
||||
*
|
||||
* @param string $expression The SQL expression to cast
|
||||
* @param string $type The abstract type: 'text', 'integer', or 'decimal'
|
||||
* @param int $precision Precision for decimal type (default: 10)
|
||||
* @param int $scale Scale for decimal type (default: 2)
|
||||
* @return string The database-specific CAST expression
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function castAs(string $expression, string $type, int $precision = 10, int $scale = 2): string
|
||||
{
|
||||
$driver = $this->db->getDriverName();
|
||||
|
||||
$targetType = match ($type) {
|
||||
'text' => match ($driver) {
|
||||
'mysql' => 'CHAR',
|
||||
'pgsql' => 'TEXT',
|
||||
'sqlsrv' => 'NVARCHAR(MAX)',
|
||||
default => 'CHAR',
|
||||
},
|
||||
'integer' => match ($driver) {
|
||||
'mysql' => 'SIGNED',
|
||||
'pgsql' => 'INTEGER',
|
||||
'sqlsrv' => 'INT',
|
||||
default => 'SIGNED',
|
||||
},
|
||||
'decimal' => match ($driver) {
|
||||
'mysql' => "DECIMAL({$precision},{$scale})",
|
||||
'pgsql' => "NUMERIC({$precision},{$scale})",
|
||||
'sqlsrv' => "DECIMAL({$precision},{$scale})",
|
||||
default => "DECIMAL({$precision},{$scale})",
|
||||
},
|
||||
default => throw new \InvalidArgumentException("Unsupported cast type: {$type}. Use 'text', 'integer', or 'decimal'."),
|
||||
};
|
||||
|
||||
return "CAST({$expression} AS {$targetType})";
|
||||
}
|
||||
}
|
||||
107
app/Core/Db/Db.php
Normal file
107
app/Core/Db/Db.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Db;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Database Class - Very simple abstraction layer for pdo connection
|
||||
*/
|
||||
class Db
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* @var ConnectionInterface Laravel database connection
|
||||
*/
|
||||
private ConnectionInterface $connection;
|
||||
|
||||
/**
|
||||
* @var DatabaseManager Laravel's database manager
|
||||
*/
|
||||
private DatabaseManager $dbManager;
|
||||
|
||||
/**
|
||||
* __construct - connect to database and select database
|
||||
*
|
||||
* @param object $app Application container
|
||||
* @param string|null $connection Connection name (defaults to configured default connection)
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($app, ?string $connection = null)
|
||||
{
|
||||
// Get Laravel's database manager from the container
|
||||
$this->dbManager = $app['db'];
|
||||
|
||||
// Use the configured default connection if none specified
|
||||
$connection = $connection ?? $app['config']->get('database.default', 'mysql');
|
||||
|
||||
// Get a connection from the manager
|
||||
try {
|
||||
$this->connection = $this->dbManager->connection($connection);
|
||||
} catch (\PDOException $e) {
|
||||
Log::error("Can't connect to database");
|
||||
throw new \Exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PDO connection (lazily retrieved from Laravel's connection pool)
|
||||
*
|
||||
* @return \PDO|null
|
||||
*/
|
||||
public function __get($name)
|
||||
{
|
||||
if ($name === 'database') {
|
||||
return $this->connection->getPdo();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Laravel ConnectionInterface
|
||||
*/
|
||||
public function getConnection(): ConnectionInterface
|
||||
{
|
||||
return $this->connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will generate a PDO binding string (":editors0,:editors1,:editors2,:editors3") to be used in a PDO
|
||||
* query that uses the IN() clause, to assist in proper PDO array bindings to avoid SQL injection.
|
||||
*
|
||||
* A counted for loop is used rather than foreach with a key to avoid issues if the array passed has any
|
||||
* arbitrary keys
|
||||
*/
|
||||
public static function arrayToPdoBindingString(string $name, int $count): string
|
||||
{
|
||||
$bindingStatement = '';
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$bindingStatement .= ':'.$name.$i;
|
||||
if ($i != $count - 1) {
|
||||
$bindingStatement .= ',';
|
||||
}
|
||||
}
|
||||
|
||||
return $bindingStatement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string to only contain letters, numbers and underscore.
|
||||
* Used for patch statements with variable column keys values
|
||||
*/
|
||||
public static function sanitizeToColumnString(string $string): string
|
||||
{
|
||||
return preg_replace('/[^a-zA-Z0-9_]/', '', $string);
|
||||
}
|
||||
|
||||
public static function sanitizeComparitorString(string $string): string
|
||||
{
|
||||
return preg_replace('/[^=<>LIKENOT]/', '', $string);
|
||||
}
|
||||
}
|
||||
15
app/Core/Db/DbColumn.php
Normal file
15
app/Core/Db/DbColumn.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Db;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute]
|
||||
class DbColumn
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
) {
|
||||
//
|
||||
}
|
||||
}
|
||||
369
app/Core/Db/Repository.php
Normal file
369
app/Core/Db/Repository.php
Normal file
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Db;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Database;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
|
||||
/**
|
||||
* Repository
|
||||
*/
|
||||
abstract class Repository
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected string $entity;
|
||||
|
||||
protected string $model;
|
||||
|
||||
/**
|
||||
* dbcall - creates a new dbcall object
|
||||
*
|
||||
* @param array $args - usually the value of func_get_args(), gives events/filters values to work with
|
||||
*/
|
||||
protected function dbcall(array ...$args): object
|
||||
{
|
||||
return new class($args, $this)
|
||||
{
|
||||
private ?PDOStatement $stmn = null;
|
||||
|
||||
private array $args;
|
||||
|
||||
private Repository $caller_class;
|
||||
|
||||
/**
|
||||
* @var \Closure|mixed|object|null
|
||||
*/
|
||||
private mixed $db;
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param array $args - usually the value of func_get_args(), gives events/filters values to work with
|
||||
* @param Repository $caller_class - the class object that was called
|
||||
*/
|
||||
public function __construct(array $args, Repository $caller_class)
|
||||
{
|
||||
$this->args = $args;
|
||||
$this->caller_class = $caller_class;
|
||||
// Use the singleton instance of Db to ensure connection pooling
|
||||
$this->db = app()->get(Db::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* prepares sql for entry; wrapper for PDO\prepare()
|
||||
*
|
||||
* @param array $args - additional arguments to pass along to prepare function
|
||||
*/
|
||||
public function prepare(string $sql, array $args = []): void
|
||||
{
|
||||
$sql = $this->caller_class::dispatch_filter(
|
||||
'sql',
|
||||
$sql,
|
||||
$this->getArgs(['prepareArgs' => $args]),
|
||||
4
|
||||
);
|
||||
|
||||
$this->stmn = $this->db->database->prepare($sql, $args);
|
||||
}
|
||||
|
||||
/**
|
||||
* binds values for search/replace of sql; wrapper for PDO\bindValue()
|
||||
*
|
||||
* @param string $needle - placeholder to replace
|
||||
* @param string $replace - value to replace with
|
||||
* @param int $type - type of value being replaced
|
||||
*/
|
||||
public function bindValue(string $needle, mixed $replace, int $type = PDO::PARAM_STR): void
|
||||
{
|
||||
$replace = $this->caller_class::dispatch_filter(
|
||||
'binding.'.str_replace(':', '', $needle),
|
||||
$replace,
|
||||
$this->getArgs(),
|
||||
4
|
||||
);
|
||||
|
||||
$this->stmn->bindValue($needle, $replace, $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* executes the sql call - uses \PDO
|
||||
*/
|
||||
public function lastInsertId(): mixed
|
||||
{
|
||||
return $this->db->database->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* executes the sql call - uses \PDO
|
||||
*/
|
||||
public function setFetchMode($mode, $class): bool
|
||||
{
|
||||
return $this->stmn->setFetchMode($mode, $class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the arguments to pass along to events/filter
|
||||
*
|
||||
* @param array $additions - any other additional parameters to include
|
||||
*/
|
||||
private function getArgs(array $additions = []): array
|
||||
{
|
||||
$args = array_merge($this->args, ['self' => $this]);
|
||||
|
||||
if (! empty($additions)) {
|
||||
$args = array_merge($args, $additions);
|
||||
}
|
||||
|
||||
$this->caller_class::dispatch_filter('args', $args, [], 5);
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* executes the sql call - uses \PDO
|
||||
*/
|
||||
public function __call(string $method, $arguments): mixed
|
||||
{
|
||||
if (! isset($this->stmn)) {
|
||||
throw new \Error("You must run the 'prepare' method first!");
|
||||
}
|
||||
|
||||
if (! in_array($method, ['execute', 'fetch', 'fetchAll'])) {
|
||||
throw new \Error('Method does not exist');
|
||||
}
|
||||
|
||||
$this->caller_class::dispatch_event('beforeExecute', $this->getArgs(), 4);
|
||||
|
||||
$this->stmn = $this->caller_class::dispatch_filter('stmn', $this->stmn, $this->getArgs(), 4);
|
||||
$method = $this->caller_class::dispatch_filter('method', $method, $this->getArgs(), 4);
|
||||
|
||||
try {
|
||||
$values = $this->stmn->execute();
|
||||
|
||||
if (in_array($method, ['fetch', 'fetchAll'])) {
|
||||
$values = $this->stmn->$method();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ensure cursor is closed even on exceptions
|
||||
if (isset($this->stmn)) {
|
||||
$this->stmn->closeCursor();
|
||||
}
|
||||
throw $e;
|
||||
} finally {
|
||||
// Always ensure proper cleanup
|
||||
if (isset($this->stmn)) {
|
||||
$this->stmn->closeCursor();
|
||||
}
|
||||
}
|
||||
|
||||
$this->caller_class::dispatch_event('afterExecute', $this->getArgs(), 4);
|
||||
|
||||
return $this->caller_class::dispatch_filter('return', $values, $this->getArgs(), 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor to ensure proper cleanup of database resources
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
if (isset($this->stmn)) {
|
||||
try {
|
||||
$this->stmn->closeCursor();
|
||||
} catch (\Exception $e) {
|
||||
// Silently handle any cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* patch - updates a record in the database
|
||||
*
|
||||
* @param int $id - the id of the record to update
|
||||
* @param array $params - the parameters to update
|
||||
*/
|
||||
public function patch(int $id, array $params): bool
|
||||
{
|
||||
|
||||
unset($params['act']);
|
||||
|
||||
if ($this->entity == '') {
|
||||
report('Patch not implemented for this entity');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$sql = 'UPDATE zp_'.$this->entity.' SET ';
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
$sql .= ''.Db::sanitizeToColumnString($key).'=:'.Db::sanitizeToColumnString($key).', ';
|
||||
}
|
||||
|
||||
$sql .= 'id=:id WHERE id=:id LIMIT 1';
|
||||
|
||||
$call = $this->dbcall(func_get_args());
|
||||
|
||||
$call->prepare($sql);
|
||||
|
||||
$call->bindValue(':id', $id, PDO::PARAM_STR);
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
$call->bindValue(':'.Db::sanitizeToColumnString($key), $value, PDO::PARAM_STR);
|
||||
}
|
||||
|
||||
return $call->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function insert(object $objectToInsert): false|int
|
||||
{
|
||||
|
||||
if ($this->entity == '') {
|
||||
report('Insert not implemented for this entity');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$sql = 'INSERT INTO zp_'.$this->entity.' (';
|
||||
|
||||
$sqlArr = [];
|
||||
foreach ($objectToInsert as $key => $value) {
|
||||
if ($this->getFieldAttribute($objectToInsert, $key)) {
|
||||
$sqlArr[] = '`'.Db::sanitizeToColumnString($key).'`';
|
||||
}
|
||||
}
|
||||
$sql .= implode(',', $sqlArr);
|
||||
|
||||
$sql .= ') VALUES (';
|
||||
|
||||
$sqlArr2 = [];
|
||||
foreach ($objectToInsert as $key => $value) {
|
||||
if ($this->getFieldAttribute($objectToInsert, $key)) {
|
||||
$sqlArr2[] = ':'.Db::sanitizeToColumnString($key).'';
|
||||
}
|
||||
}
|
||||
$sql .= implode(',', $sqlArr2);
|
||||
|
||||
$sql .= ')';
|
||||
|
||||
$call = $this->dbcall(func_get_args());
|
||||
|
||||
$call->prepare($sql);
|
||||
|
||||
foreach ($objectToInsert as $key => $value) {
|
||||
if ($this->getFieldAttribute($objectToInsert, $key)) {
|
||||
$call->bindValue(':'.Db::sanitizeToColumnString($key), $value, PDO::PARAM_STR);
|
||||
}
|
||||
}
|
||||
|
||||
$call->execute();
|
||||
|
||||
return $call->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* delete - deletes a record from the database
|
||||
*
|
||||
* @param int $id - the id of the record to delete
|
||||
*/
|
||||
public function delete(int $id): void {}
|
||||
|
||||
/**
|
||||
* get - gets a record from the database
|
||||
*
|
||||
* @param int $id - the id of the record to get
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function get(int $id): mixed
|
||||
{
|
||||
if ($this->entity == '' || $this->model == '') {
|
||||
report('Get not implemented for this entity');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$sql = 'SELECT ';
|
||||
|
||||
$entityModel = app()->make($this->model);
|
||||
$dbFields = $this->getDbFields($this->model);
|
||||
|
||||
$sql .= implode(',', $dbFields);
|
||||
|
||||
$sql .= ' FROM zp_'.$this->entity.' WHERE id = :id ';
|
||||
|
||||
$call = $this->dbcall(func_get_args());
|
||||
|
||||
$call->prepare($sql);
|
||||
|
||||
$call->bindValue(':id', $id, PDO::PARAM_STR);
|
||||
|
||||
$call->execute();
|
||||
|
||||
$call->setFetchMode(PDO::FETCH_CLASS, $this->model);
|
||||
|
||||
return $call->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* getFieldAttribute - gets the field attribute for a given property
|
||||
*
|
||||
* @param object|string $class - the class to get the attribute from
|
||||
* @param string $property - the property to get the attribute from
|
||||
* @param bool $includeId - whether or not to include the id attribute
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
protected function getFieldAttribute(object|string $class, string $property, bool $includeId = false): array|false
|
||||
{
|
||||
// Don't create or update id attributes
|
||||
if ($includeId === false && $property == 'id') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$property = new ReflectionProperty($class, $property);
|
||||
|
||||
$attributes = $property->getAttributes();
|
||||
foreach ($attributes as $attribute) {
|
||||
$name = $attribute->getName();
|
||||
if (str_contains($name, 'DbColumn')) {
|
||||
return $attribute->getArguments();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* getDbFields - gets the database fields for a given class
|
||||
*
|
||||
* @param object|string $class - the class to get the fields from
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
protected function getDbFields(object|string $class): array
|
||||
{
|
||||
$property = new ReflectionClass($class);
|
||||
|
||||
$properties = $property->getProperties();
|
||||
|
||||
$propertyArray = [];
|
||||
foreach ($properties as $property) {
|
||||
if ($this->getFieldAttribute($class, $property->getName(), true)) {
|
||||
$propertyArray[] = $property->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return $propertyArray;
|
||||
}
|
||||
}
|
||||
108
app/Core/Domains/BaseService.php
Normal file
108
app/Core/Domains/BaseService.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Domains;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Exceptions\ValidationException;
|
||||
|
||||
/**
|
||||
* Base class for domain services, providing the cross-cutting authorization and validation
|
||||
* helpers a service needs. All services should extend this (it also carries the
|
||||
* {@see DomainService} marker and the {@see DispatchesEvents} trait, so event behavior is
|
||||
* unchanged).
|
||||
*
|
||||
* Dependency wiring is handled by {@see \Leantime\Core\Auth\Permissions\PermissionServiceProvider}:
|
||||
* an `afterResolving(BaseService::class, ...)` hook wires a LAZY resolver (not the instance) on
|
||||
* every container-resolved subclass. That keeps the engine injected with zero constructor
|
||||
* boilerplate in subclasses (which all have their own repo-injecting constructors) and without
|
||||
* reaching for the `app()` helper inside service methods. The resolver — rather than eager
|
||||
* injection — is essential: a service can sit inside PermissionService's own dependency graph
|
||||
* (the Files service is reached via PermissionService → ChecksProjectAccess → Projects → Files),
|
||||
* so eagerly making PermissionService inside that service's afterResolving hook would re-enter
|
||||
* PermissionService's half-built construction and recurse forever. Resolving lazily on first
|
||||
* authorize()/can() defers it until the singleton exists.
|
||||
*/
|
||||
abstract class BaseService implements DomainService
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected ?PermissionService $permissions = null;
|
||||
|
||||
/** @var (\Closure(): PermissionService)|null Lazy resolver wired by PermissionServiceProvider. */
|
||||
protected ?\Closure $permissionServiceResolver = null;
|
||||
|
||||
/**
|
||||
* Set the engine instance directly. Used by unit tests; production uses the lazy resolver below.
|
||||
*/
|
||||
public function setPermissionService(PermissionService $permissions): void
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a LAZY resolver instead of the instance (see the class docblock for why eager injection
|
||||
* recurses). The engine is resolved on first authorize()/can().
|
||||
*/
|
||||
public function setPermissionServiceResolver(\Closure $resolver): void
|
||||
{
|
||||
$this->permissionServiceResolver = $resolver;
|
||||
}
|
||||
|
||||
/** Resolve the engine, preferring a directly-set instance (tests) then the lazy resolver. */
|
||||
private function permissionService(): PermissionService
|
||||
{
|
||||
if ($this->permissions === null) {
|
||||
if ($this->permissionServiceResolver === null) {
|
||||
throw new \LogicException(static::class.' has no PermissionService: it was neither resolved through the container nor wired in a test.');
|
||||
}
|
||||
|
||||
$this->permissions = ($this->permissionServiceResolver)();
|
||||
}
|
||||
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize the current user for a `domain.action` permission or throw. Replaces the
|
||||
* silent `return false` pattern — a denial becomes an
|
||||
* {@see \Leantime\Core\Exceptions\AuthorizationException} (403 web / RPC -32001).
|
||||
*
|
||||
* @throws \Leantime\Core\Exceptions\AuthorizationException
|
||||
*/
|
||||
protected function authorize(string $permission, ?int $projectId = null, ?bool $forceGlobal = null): void
|
||||
{
|
||||
$this->permissionService()->authorize($permission, $projectId, $forceGlobal);
|
||||
}
|
||||
|
||||
/** Non-throwing capability check, for branching. */
|
||||
protected function can(string $permission, ?int $projectId = null, ?bool $forceGlobal = null): bool
|
||||
{
|
||||
return $this->permissionService()->currentUserCan($permission, $projectId, $forceGlobal);
|
||||
}
|
||||
|
||||
/** The authenticated user's id, or null when there is no session user. */
|
||||
protected function currentUserId(): ?int
|
||||
{
|
||||
$id = session('userdata.id');
|
||||
|
||||
return ($id === null || $id === '') ? null : (int) $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input against Laravel rules, returning the validated (whitelisted) subset or
|
||||
* throwing a {@see ValidationException} (422 web / RPC -32602 with field errors). Works
|
||||
* identically whether input arrived via a controller or JSON-RPC.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $rules
|
||||
* @param array<string, string> $messages
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
protected function validate(array $data, array $rules, array $messages = []): array
|
||||
{
|
||||
return ValidationException::validate($data, $rules, $messages);
|
||||
}
|
||||
}
|
||||
85
app/Core/Domains/DTO.php
Normal file
85
app/Core/Domains/DTO.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Domains;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
abstract class DTO
|
||||
{
|
||||
/**
|
||||
* @param Response|array $data The data to map to the DTO
|
||||
**/
|
||||
public function __construct(private Response|array $data)
|
||||
{
|
||||
$data = Arr::dot($data instanceof Response ? $data->json() : $data);
|
||||
$builder = build($this);
|
||||
$propertyAttributes = collect((new \ReflectionClass($this))->getProperties())->mapWithKeys(function ($property) {
|
||||
$property->setAccessible(true);
|
||||
|
||||
return [$property->getName() => collect($property->getAttributes())->mapWithKeys(fn ($attr) => [$attr->getName() => $attr->getArguments()])];
|
||||
})->all();
|
||||
$propertyAttributes = Arr::dot($propertyAttributes);
|
||||
|
||||
foreach ($data as $placement => $value) {
|
||||
$propertyPath = explode('.', Str::beforeLast($placement, '.'));
|
||||
$propertyName = array_shift($propertyPath);
|
||||
|
||||
if (
|
||||
($propKey = array_search($placement, $propertyAttributes))
|
||||
&& Str::afterLast($propKey, '.') == 'Map'
|
||||
) {
|
||||
$propertyPath = explode('.', Str::beforeLast($propKey, '.'));
|
||||
$propertyName = array_shift($propertyPath);
|
||||
}
|
||||
|
||||
$placement = implode('.', array_filter([$propertyName, ...$propertyPath]));
|
||||
$attributes = array_filter(
|
||||
$propertyAttributes,
|
||||
fn ($key) => Str::beforeLast('.', $key) == $placement && Str::afterLast('.', $key) !== 'Map',
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
|
||||
foreach ($attributes as $key => $attrValue) {
|
||||
$attrName = Str::afterLast($key, '.');
|
||||
$value = $this->{Str::camel($attrName)}(params: $attrValue, value: $value);
|
||||
}
|
||||
|
||||
$builder->set($placement, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates values.
|
||||
*
|
||||
* @param string[] $params validations rules to apply to the value
|
||||
*
|
||||
* @todo Implement. May use illuminate/validation later on.
|
||||
*
|
||||
* @see https://github.com/mattstauffer/Torch/tree/master/components/validation
|
||||
**/
|
||||
private function validate(array $params, mixed $value): mixed
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the DTO data as multidimensional an array
|
||||
**/
|
||||
public function toArray(): array
|
||||
{
|
||||
$props = get_class_vars($this::class);
|
||||
unset($props['data']);
|
||||
|
||||
return collect($props)->map(fn ($defaultVal, $key) => $this->{$key} ?? $defaultVal)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the DTO data as multidimensional an array
|
||||
**/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
}
|
||||
5
app/Core/Domains/DomainModel.php
Normal file
5
app/Core/Domains/DomainModel.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Domains;
|
||||
|
||||
interface DomainModel {}
|
||||
58
app/Core/Domains/DomainRepository.php
Normal file
58
app/Core/Domains/DomainRepository.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Domains;
|
||||
|
||||
/**
|
||||
* Service Interface - Base interface for all services
|
||||
*/
|
||||
interface DomainRepository
|
||||
{
|
||||
/**
|
||||
* patches the object by key.
|
||||
*
|
||||
* @param int $id Id of the object to be patched
|
||||
* @param array $params Key=>value array where key represents the object field name and value the value.
|
||||
* @return bool returns true on success, false on failure
|
||||
*/
|
||||
public function patch(int $id, array $params): bool;
|
||||
|
||||
/**
|
||||
* updates the object by key.
|
||||
*
|
||||
* @param object|array $object expects the entire object to be updated as object or array
|
||||
* @return array|bool Returns true on success, false on failure
|
||||
*/
|
||||
public function update(object|array $object): array|bool;
|
||||
|
||||
/**
|
||||
* Creates a new object
|
||||
*
|
||||
* @param object|array $object Object or array to be created
|
||||
* @return int|false Returns id of new element or false
|
||||
*/
|
||||
public function create(object|array $object): int|false;
|
||||
|
||||
/**
|
||||
* Deletes object
|
||||
*
|
||||
* @param int $id Id of the object to be deleted
|
||||
* @return bool Returns id of new element or false
|
||||
*/
|
||||
public function delete(int $id);
|
||||
|
||||
/**
|
||||
* Gets 1 specific item
|
||||
*
|
||||
* @param int $id Id of the object to be retrieved
|
||||
* @return object|array|false Returns object or array. False on failure or if item cannot be found
|
||||
*/
|
||||
public function get(int $id);
|
||||
|
||||
/**
|
||||
* Get all items
|
||||
*
|
||||
* @param array|null $searchparams Search parameters
|
||||
* @return array|false Returns array on success, false on failure. No results should return empty array
|
||||
*/
|
||||
public function query(?array $searchparams = null);
|
||||
}
|
||||
18
app/Core/Domains/DomainService.php
Normal file
18
app/Core/Domains/DomainService.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Domains;
|
||||
|
||||
/**
|
||||
* Marker interface for all domain (and plugin) service classes.
|
||||
*
|
||||
* It carries no required methods on purpose. Real services have wildly different shapes
|
||||
* (the Tickets service alone has ~75 heterogeneous methods), so a fixed CRUD contract
|
||||
* never fit — which is exactly why the previous patch/update/create/delete/get/query
|
||||
* interface had zero implementers. This marker instead gives the service layer a single
|
||||
* type to scan for and a shared home (via {@see BaseService}) for the cross-cutting
|
||||
* authorize()/validate() helpers, without forcing a fictional method surface.
|
||||
*
|
||||
* Granular capability interfaces (e.g. a real Crudable) may be introduced alongside this
|
||||
* marker where they genuinely apply, opt-in per service.
|
||||
*/
|
||||
interface DomainService {}
|
||||
44
app/Core/Encryption/EncryptionServiceProvider.php
Normal file
44
app/Core/Encryption/EncryptionServiceProvider.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Encryption;
|
||||
|
||||
class EncryptionServiceProvider extends \Illuminate\Encryption\EncryptionServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->registerEncrypter();
|
||||
$this->registerSerializableClosureSecurityKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the encrypter.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerEncrypter()
|
||||
{
|
||||
|
||||
$this->app->singleton('encrypter', function ($app) {
|
||||
|
||||
$configKey = $app['config']->sessionPassword;
|
||||
|
||||
if (strlen($configKey) > 32) {
|
||||
$configKey = substr($configKey, 0, 32);
|
||||
}
|
||||
|
||||
if (strlen($configKey) < 32) {
|
||||
$configKey = str_pad($configKey, 32, 'x', STR_PAD_BOTH);
|
||||
}
|
||||
|
||||
$app['config']['app_key'] = $configKey;
|
||||
$app['config']['key'] = $configKey;
|
||||
|
||||
return new \Illuminate\Encryption\Encrypter($configKey, 'AES-256-CBC');
|
||||
});
|
||||
}
|
||||
}
|
||||
42
app/Core/Events/Concerns/InteractsWithEvents.php
Normal file
42
app/Core/Events/Concerns/InteractsWithEvents.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Concerns;
|
||||
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
|
||||
/**
|
||||
* Shared behavior for class-based domain events.
|
||||
*
|
||||
* Provides the static dispatch() ergonomic and a default empty legacyHooks() so events
|
||||
* introduced after the class-based system don't need to declare one:
|
||||
*
|
||||
* TicketUpdated::dispatch(ticketId: $id);
|
||||
*
|
||||
* Do NOT combine with Laravel's Dispatchable trait — both define dispatch(), and the
|
||||
* Dispatchable version routes through the generic object path instead of the
|
||||
* LeantimeEvent fast path.
|
||||
*/
|
||||
trait InteractsWithEvents
|
||||
{
|
||||
/**
|
||||
* Default: no legacy string names. Override during the migration window with the
|
||||
* exact historical leantime.* name of the CURRENT emit site — rebuilt from a
|
||||
* `legacyHook: __FUNCTION__` constructor discriminator when several methods
|
||||
* historically fired the same raw hook (see the LeantimeEvent docblock).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct and dispatch this event through the Leantime event dispatcher.
|
||||
* Arguments (named arguments included) are forwarded to the constructor.
|
||||
*/
|
||||
public static function dispatch(mixed ...$args): void
|
||||
{
|
||||
EventDispatcher::dispatch_event(new static(...$args));
|
||||
}
|
||||
}
|
||||
57
app/Core/Events/Concerns/InteractsWithFilters.php
Normal file
57
app/Core/Events/Concerns/InteractsWithFilters.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Concerns;
|
||||
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
|
||||
/**
|
||||
* Shared behavior for class-based filters.
|
||||
*
|
||||
* Provides the static dispatch() / instance apply() ergonomics and sensible defaults
|
||||
* for the LeantimeFilter contract:
|
||||
*
|
||||
* $tickets = TodoWidgetTasksFilter::dispatch(tickets: $tickets, userId: $userId);
|
||||
*
|
||||
* The default payload() returns the $payload property; filter classes that name their
|
||||
* payload something more meaningful (e.g. public array $tickets) override payload().
|
||||
*/
|
||||
trait InteractsWithFilters
|
||||
{
|
||||
/**
|
||||
* Default: no legacy string names. Override during the migration window with the
|
||||
* exact historical leantime.* name of the CURRENT emit site — rebuilt from a
|
||||
* `legacyHook: __FUNCTION__` constructor discriminator when several methods
|
||||
* historically ran the same raw hook (see the LeantimeFilter docblock).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default payload accessor. Override when the payload property has a domain name.
|
||||
*/
|
||||
public function payload(): mixed
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the filter and run the pipeline, returning the filtered payload.
|
||||
* Arguments (named arguments included) are forwarded to the constructor.
|
||||
*/
|
||||
public static function dispatch(mixed ...$args): mixed
|
||||
{
|
||||
return EventDispatcher::dispatch_class_filter(new static(...$args));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pipeline for an already-constructed filter, returning the filtered payload.
|
||||
*/
|
||||
public function apply(): mixed
|
||||
{
|
||||
return EventDispatcher::dispatch_class_filter($this);
|
||||
}
|
||||
}
|
||||
53
app/Core/Events/Contracts/LeantimeEvent.php
Normal file
53
app/Core/Events/Contracts/LeantimeEvent.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Contracts;
|
||||
|
||||
/**
|
||||
* Contract for class-based domain events.
|
||||
*
|
||||
* Event classes live in app/Domain/{Domain}/Events/ (or app/Core/{Module}/Events/ for
|
||||
* core modules), are named {Entity}{Verb} with the verb taken from the central
|
||||
* {@see \Leantime\Core\Events\EventVerb} vocabulary, and carry their typed payload as
|
||||
* public (constructor-promoted) properties.
|
||||
*
|
||||
* Listeners subscribe to the class itself:
|
||||
*
|
||||
* EventDispatcher::add_event_listener(TicketUpdated::class, MyListener::class);
|
||||
*
|
||||
* and receive the bare event object — `MyListener::handle(TicketUpdated $event)`.
|
||||
*
|
||||
* MIGRATION WINDOW: legacyHooks() returns the exact historical string name(s) the
|
||||
* CURRENT emit site fired under (the auto-generated
|
||||
* leantime.domain.{...}.{method}.{rawHook} strings). The dispatcher dual-emits to those
|
||||
* names so existing string/wildcard listeners — plugins in particular — keep firing
|
||||
* with today's array payload, without coordinated releases.
|
||||
*
|
||||
* IMPORTANT: never statically list ALL historical emit sites — that would fire every
|
||||
* site's name on every dispatch (exact subscribers fire under the wrong conditions,
|
||||
* wildcard subscribers fire once per name instead of once per event). When the same
|
||||
* raw hook historically fired from several methods, take a constructor discriminator
|
||||
* and have each call site pass its own method name — `legacyHook: __FUNCTION__` — so
|
||||
* each dispatch rebuilds the single name that site produced (see the Tickets pilot
|
||||
* events for the pattern).
|
||||
*
|
||||
* Remove the entries (and eventually the mechanism) once all consumers have migrated
|
||||
* to the FQCN. Mirrors the client-side
|
||||
* {@see \Leantime\Core\Events\Htmx\HtmxEvents} LEGACY_ALIASES window.
|
||||
*
|
||||
* Use the {@see \Leantime\Core\Events\Concerns\InteractsWithEvents} trait for the
|
||||
* static dispatch() ergonomic and the default empty legacyHooks().
|
||||
*/
|
||||
interface LeantimeEvent
|
||||
{
|
||||
/**
|
||||
* The exact historical dotted string name(s) the CURRENT emit site fired under —
|
||||
* at most one name per dispatch in practice. When several methods historically
|
||||
* emitted the same raw hook, rebuild the right name from a
|
||||
* `legacyHook: __FUNCTION__` constructor discriminator; never statically list all
|
||||
* sites (see class docblock). Empty for events introduced after the class-based
|
||||
* system.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function legacyHooks(): array;
|
||||
}
|
||||
45
app/Core/Events/Contracts/LeantimeFilter.php
Normal file
45
app/Core/Events/Contracts/LeantimeFilter.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Contracts;
|
||||
|
||||
/**
|
||||
* Contract for class-based filters (the return-value pipeline counterpart of events).
|
||||
*
|
||||
* Filter classes live next to events in Events/, are named {Thing}Filter
|
||||
* (TodoWidgetTasksFilter), hold the initial payload plus typed context as public
|
||||
* (constructor-promoted) properties, and return the filtered payload from apply().
|
||||
*
|
||||
* Listeners subscribe to the class itself and keep the familiar filter signature —
|
||||
* they receive the current payload and the filter object as context, and must return
|
||||
* the (possibly modified) payload:
|
||||
*
|
||||
* EventDispatcher::add_filter_listener(TodoWidgetTasksFilter::class,
|
||||
* fn ($tickets, TodoWidgetTasksFilter $filter) => $tickets);
|
||||
*
|
||||
* MIGRATION WINDOW: legacyHooks() returns the exact historical string name(s) of the
|
||||
* CURRENT emit site; the dispatcher threads the payload through listeners on the FQCN
|
||||
* first, then through each legacy name where listeners receive today's
|
||||
* ($payload, $availableParams) array signature unchanged. When several methods
|
||||
* historically ran the same raw hook, rebuild the right name from a
|
||||
* `legacyHook: __FUNCTION__` constructor discriminator — never statically list all
|
||||
* sites. See {@see LeantimeEvent} for the full rationale.
|
||||
*
|
||||
* Use the {@see \Leantime\Core\Events\Concerns\InteractsWithFilters} trait for the
|
||||
* payload()/apply() plumbing and the default empty legacyHooks().
|
||||
*/
|
||||
interface LeantimeFilter
|
||||
{
|
||||
/**
|
||||
* The initial payload to thread through the filter pipeline.
|
||||
*/
|
||||
public function payload(): mixed;
|
||||
|
||||
/**
|
||||
* The exact historical dotted string name(s) the CURRENT emit site ran under.
|
||||
* Use a `legacyHook: __FUNCTION__` constructor discriminator when several methods
|
||||
* historically ran the same raw hook (see class docblock).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function legacyHooks(): array;
|
||||
}
|
||||
90
app/Core/Events/DispatchesEvents.php
Normal file
90
app/Core/Events/DispatchesEvents.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events;
|
||||
|
||||
trait DispatchesEvents
|
||||
{
|
||||
private static string $event_context = '';
|
||||
|
||||
/**
|
||||
* dispatches an event with context
|
||||
*/
|
||||
public static function dispatch_event(string $hook, mixed $available_params = [], string|int|null $function = null): void
|
||||
{
|
||||
EventDispatcher::dispatch_event($hook, $available_params, static::get_event_context($function));
|
||||
}
|
||||
|
||||
// The new dispatchEvent method is below. We're keeping both for backwards compatibility until v4.0
|
||||
// Temporary for backwards compatibility
|
||||
public static function dispatchEvent(string $hook, mixed $available_params = [], string|int|null $function = null): void
|
||||
{
|
||||
EventDispatcher::dispatch_event($hook, $available_params, static::get_event_context($function));
|
||||
}
|
||||
|
||||
/**
|
||||
* dispatches a filter with context
|
||||
*/
|
||||
public static function dispatch_filter(string $hook, mixed $payload, mixed $available_params = [], string|int|null $function = null): mixed
|
||||
{
|
||||
return EventDispatcher::dispatch_filter($hook, $payload, $available_params, static::get_event_context($function));
|
||||
}
|
||||
|
||||
// The new dispatchEvent method is below. We're keeping both for backwards compatibility until v4.0
|
||||
// Temporary for backwards compatibility
|
||||
public static function dispatchFilter(string $hook, mixed $payload, mixed $available_params = [], string|int|null $function = null): mixed
|
||||
{
|
||||
return EventDispatcher::dispatch_filter($hook, $payload, $available_params, static::get_event_context($function));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the context of the event
|
||||
*/
|
||||
protected static function get_event_context($function): string
|
||||
{
|
||||
if (empty(self::$event_context)) {
|
||||
self::$event_context = static::set_class_context();
|
||||
}
|
||||
|
||||
$eventContext = self::$event_context.'.';
|
||||
|
||||
if (! empty($function) && is_string($function) && ! is_numeric($function)) {
|
||||
|
||||
$function = $function;
|
||||
|
||||
// If context starts with leantime, the full context was provided by caller
|
||||
if (str_starts_with($function, 'leantime.')) {
|
||||
$eventContext = '';
|
||||
}
|
||||
|
||||
} else {
|
||||
$function = static::get_function_context(is_numeric($function) ? (int) $function : null);
|
||||
}
|
||||
|
||||
return $eventContext.$function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the class Context based on path, this uses the same method as the autoloader
|
||||
* Helps create unique strings for events/filters
|
||||
*/
|
||||
protected static function set_class_context(): string
|
||||
{
|
||||
return str_replace('\\', '.', strtolower(static::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the caller function name.
|
||||
*
|
||||
* Uses debug_backtrace with limited depth and no args instead of
|
||||
* Exception::getTrace() to avoid the overhead of creating a full
|
||||
* exception object on every event dispatch (~60 times per request).
|
||||
*/
|
||||
protected static function get_function_context(?int $functionInt = null): string
|
||||
{
|
||||
$tracePointer = is_int($functionInt) ? $functionInt : 3;
|
||||
|
||||
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $tracePointer + 1);
|
||||
|
||||
return $trace[$tracePointer]['function'] ?? '';
|
||||
}
|
||||
}
|
||||
883
app/Core/Events/EventDispatcher.php
Normal file
883
app/Core/Events/EventDispatcher.php
Normal file
@@ -0,0 +1,883 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Traits\Macroable;
|
||||
use Illuminate\Support\Traits\ReflectsClosures;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Events\Contracts\LeantimeEvent;
|
||||
use Leantime\Core\Events\Contracts\LeantimeFilter;
|
||||
use Leantime\Core\Routing\RouteLoader;
|
||||
|
||||
/**
|
||||
* EventDispatcher class - Handles all events and filters
|
||||
*/
|
||||
class EventDispatcher implements Dispatcher
|
||||
{
|
||||
use Macroable;
|
||||
use ReflectsClosures;
|
||||
|
||||
/**
|
||||
* Cache for pattern matching results
|
||||
*/
|
||||
private static array $patternMatchCache = [];
|
||||
|
||||
/**
|
||||
* Cache of compiled regex patterns, keyed by registry key. A registry key
|
||||
* always compiles to the same regex, so this is computed once per key for the
|
||||
* whole request instead of recompiling the entire registry on every distinct
|
||||
* event name (the previous per-call local cache meant hundreds of cache misses
|
||||
* each recompiled every pattern).
|
||||
*/
|
||||
private static array $compiledPatternCache = [];
|
||||
|
||||
/**
|
||||
* Version counters for registry change tracking.
|
||||
* Incremented when listeners are added, used for cache key generation
|
||||
* instead of expensive md5(serialize(array_keys($registry))) on every dispatch.
|
||||
*/
|
||||
private static int $eventRegistryVersion = 0;
|
||||
|
||||
private static int $filterRegistryVersion = 0;
|
||||
|
||||
/**
|
||||
* Registry of all events added to a hook
|
||||
*/
|
||||
private static array $eventRegistry = [];
|
||||
|
||||
/**
|
||||
* Registry of all filters added to a hook
|
||||
*/
|
||||
private static array $filterRegistry = [];
|
||||
|
||||
/**
|
||||
* Registry of all hooks available
|
||||
*/
|
||||
private static array $available_hooks = [
|
||||
'filters' => [],
|
||||
'events' => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* Finds event listeners by event names,
|
||||
* Allows listeners with wildcards
|
||||
*/
|
||||
public static function findEventListeners(string $eventName, array $registry): array
|
||||
{
|
||||
// Use version counters for cache key instead of expensive md5(serialize(array_keys()))
|
||||
$registryVersion = ($registry === self::$eventRegistry)
|
||||
? self::$eventRegistryVersion
|
||||
: self::$filterRegistryVersion;
|
||||
$cacheKey = $eventName.'_'.$registryVersion;
|
||||
|
||||
if (isset(self::$patternMatchCache[$cacheKey])) {
|
||||
return self::$patternMatchCache[$cacheKey];
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($registry as $key => $value) {
|
||||
// Compile each registry key's regex once for the whole request. The
|
||||
// compiled pattern depends only on the key, not the event name.
|
||||
if (! isset(self::$compiledPatternCache[$key])) {
|
||||
preg_match_all('/\{RGX:(.*?):RGX\}/', $key, $regexMatches);
|
||||
self::$compiledPatternCache[$key] = self::compilePattern($key, $regexMatches);
|
||||
}
|
||||
|
||||
if (preg_match('/^'.self::$compiledPatternCache[$key].'$/', $eventName)) {
|
||||
$matches = array_merge($matches, $value);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
self::$patternMatchCache[$cacheKey] = $matches;
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a pattern for matching
|
||||
*/
|
||||
private static function compilePattern(string $key, array $regexMatches): string
|
||||
{
|
||||
$key = strtr($key, [
|
||||
...collect($regexMatches[0] ?? [])->mapWithKeys(fn ($match, $i) => [$match => "REGEX_MATCH_$i"])->toArray(),
|
||||
'*' => 'RANDOM_STRING',
|
||||
'?' => 'RANDOM_CHARACTER',
|
||||
]);
|
||||
|
||||
$pattern = preg_quote($key, '/');
|
||||
|
||||
return strtr($pattern, [
|
||||
'RANDOM_STRING' => '.*?',
|
||||
'RANDOM_CHARACTER' => '.',
|
||||
...collect($regexMatches[1] ?? [])->mapWithKeys(fn ($match, $i) => ["REGEX_MATCH_$i" => $match])->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function dispatch(
|
||||
$event,
|
||||
$payload = [],
|
||||
$halt = false
|
||||
) {
|
||||
|
||||
$this->dispatch_event($event, $payload, '');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function dispatch_filter(
|
||||
string $filtername,
|
||||
mixed $payload = '',
|
||||
mixed $available_params = [],
|
||||
mixed $context = ''
|
||||
): mixed {
|
||||
$filtername = "$context.$filtername";
|
||||
|
||||
if (! in_array($filtername, self::$available_hooks['filters'])) {
|
||||
self::$available_hooks['filters'][] = $filtername;
|
||||
}
|
||||
|
||||
$matchedEvents = self::findEventListeners($filtername, self::$filterRegistry);
|
||||
if (count($matchedEvents) == 0) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$available_params = self::defineParams($available_params, $filtername);
|
||||
|
||||
return self::executeHandlers($matchedEvents, 'filters', $filtername, $payload, $available_params);
|
||||
}
|
||||
|
||||
public static function dispatch_event(
|
||||
$event,
|
||||
mixed $payload = [],
|
||||
string $context = ''
|
||||
): void {
|
||||
|
||||
// Class-based events bypass the string machinery (context building, payload
|
||||
// wrapping) entirely: listeners on the FQCN receive the typed object, listeners
|
||||
// on the declared legacy string names receive today's array payload.
|
||||
if ($event instanceof LeantimeEvent) {
|
||||
self::executeClassEventHandlers($event);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Laravel events can be objects. Let's get those into the right format
|
||||
// Event comes out as string, either as class string or regular old string
|
||||
// No-op for leantime events
|
||||
[$event, $payload] = [
|
||||
...self::parseEventAndPayload($event, $payload),
|
||||
];
|
||||
|
||||
if (! empty($context)) {
|
||||
$event = "$context.$event";
|
||||
}
|
||||
|
||||
if (! in_array($event, self::$available_hooks['events'])) {
|
||||
self::$available_hooks['events'][] = $event;
|
||||
}
|
||||
|
||||
$matchedEvents = self::findEventListeners($event, self::$eventRegistry);
|
||||
if (count($matchedEvents) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['leantime'] = self::defineParams($payload, $event);
|
||||
$payload['laravel'] = $payload;
|
||||
|
||||
self::executeHandlers($matchedEvents, 'events', $event, $payload);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes listeners for a class-based event.
|
||||
*
|
||||
* Listeners registered on the event's FQCN run first (priority-sorted) and receive
|
||||
* the bare typed event object. Then, for each declared legacy hook name, listeners
|
||||
* registered on (or wildcard-matching) that string run through the exact same code
|
||||
* path as string events, receiving today's array payload built from the event's
|
||||
* public properties — existing string/wildcard listeners (plugins) keep working
|
||||
* unchanged during the migration window.
|
||||
*/
|
||||
private static function executeClassEventHandlers(LeantimeEvent $event): void
|
||||
{
|
||||
$fqcn = get_class($event);
|
||||
$legacyHooks = $event->legacyHooks();
|
||||
|
||||
foreach ([$fqcn, ...$legacyHooks] as $name) {
|
||||
if (! in_array($name, self::$available_hooks['events'])) {
|
||||
self::$available_hooks['events'][] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$matched = self::findEventListeners($fqcn, self::$eventRegistry);
|
||||
if (count($matched) > 0) {
|
||||
usort($matched, fn ($a, $b) => $a['priority'] <=> $b['priority']);
|
||||
|
||||
try {
|
||||
foreach ($matched as $listener) {
|
||||
$callable = self::resolveClassHookCallable($listener['listener']);
|
||||
$callable($event);
|
||||
}
|
||||
} catch (\TypeError $e) {
|
||||
Log::error($e);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($legacyHooks)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$legacyPayload = get_object_vars($event);
|
||||
|
||||
foreach ($legacyHooks as $legacyName) {
|
||||
$matched = self::findEventListeners($legacyName, self::$eventRegistry);
|
||||
if (count($matched) == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = $legacyPayload;
|
||||
$payload['leantime'] = self::defineParams($legacyPayload, $legacyName);
|
||||
$payload['laravel'] = $payload;
|
||||
|
||||
self::executeHandlers($matched, 'events', $legacyName, $payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a class-based filter, threading the payload through listeners on the
|
||||
* FQCN first (signature: fn ($payload, LeantimeFilter $filter)), then through each
|
||||
* declared legacy hook name where listeners keep today's
|
||||
* fn ($payload, $availableParams) signature. Returns the final payload.
|
||||
*
|
||||
* Filter listeners are deliberately NOT deduplicated across name groups: threading
|
||||
* order is semantic, and a listener registered on both the FQCN and a legacy name
|
||||
* is a registration error that should surface, not be silently absorbed.
|
||||
*/
|
||||
public static function dispatch_class_filter(LeantimeFilter $filter): mixed
|
||||
{
|
||||
$fqcn = get_class($filter);
|
||||
$legacyHooks = $filter->legacyHooks();
|
||||
|
||||
foreach ([$fqcn, ...$legacyHooks] as $name) {
|
||||
if (! in_array($name, self::$available_hooks['filters'])) {
|
||||
self::$available_hooks['filters'][] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = $filter->payload();
|
||||
|
||||
$matched = self::findEventListeners($fqcn, self::$filterRegistry);
|
||||
if (count($matched) > 0) {
|
||||
usort($matched, fn ($a, $b) => $a['priority'] <=> $b['priority']);
|
||||
|
||||
try {
|
||||
foreach ($matched as $listener) {
|
||||
$callable = self::resolveClassHookCallable($listener['listener']);
|
||||
$payload = $callable($payload, $filter);
|
||||
}
|
||||
} catch (\TypeError $e) {
|
||||
Log::error($e);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($legacyHooks as $legacyName) {
|
||||
$matched = self::findEventListeners($legacyName, self::$filterRegistry);
|
||||
if (count($matched) == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$availableParams = self::defineParams(get_object_vars($filter), $legacyName);
|
||||
$payload = self::executeHandlers($matched, 'filters', $legacyName, $payload, $availableParams);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a listener registration (closure, callable, "Class", "Class@method",
|
||||
* [Class::class, 'method']) into a callable for class-based hooks. Class listeners
|
||||
* are instantiated through the container so constructor DI works; the method
|
||||
* defaults to handle(), falling back to __invoke().
|
||||
*/
|
||||
private static function resolveClassHookCallable(mixed $listener): callable
|
||||
{
|
||||
if (is_string($listener) && ! function_exists($listener)) {
|
||||
[$class, $method] = self::parseClassCallable($listener);
|
||||
|
||||
if (! method_exists($class, $method)) {
|
||||
$method = '__invoke';
|
||||
}
|
||||
|
||||
return [app()->make($class), $method];
|
||||
}
|
||||
|
||||
if (is_array($listener) && isset($listener[0]) && is_string($listener[0])) {
|
||||
[$class, $method] = [$listener[0], $listener[1] ?? 'handle'];
|
||||
|
||||
if (! method_exists($class, $method)) {
|
||||
$method = '__invoke';
|
||||
}
|
||||
|
||||
return [app()->make($class), $method];
|
||||
}
|
||||
|
||||
return $listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the current_route to the event's/filter's available params
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
private static function defineParams(mixed $paramAttr, string $eventName): array
|
||||
{
|
||||
// Cache the current route for the duration of the request since it doesn't change
|
||||
static $current_route = null;
|
||||
$current_route ??= Frontcontroller::getCurrentRoute();
|
||||
|
||||
$default_params = [
|
||||
'current_route' => $current_route,
|
||||
'currentEvent' => $eventName,
|
||||
];
|
||||
|
||||
if (! is_array($paramAttr)) {
|
||||
$paramAttr = [$paramAttr];
|
||||
}
|
||||
|
||||
$paramAttr = array_merge($default_params, $paramAttr);
|
||||
|
||||
return $paramAttr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given event and payload and prepare them for dispatching.
|
||||
*
|
||||
* @param mixed $event
|
||||
* @param mixed $payload
|
||||
* @return array
|
||||
*/
|
||||
protected static function parseEventAndPayload($event, $payload)
|
||||
{
|
||||
if (is_object($event)) {
|
||||
[$payload, $event] = [[$event], get_class($event)];
|
||||
}
|
||||
|
||||
return [$event, Arr::wrap($payload)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes all the handlers for a given hook
|
||||
*
|
||||
* @return array|object|null
|
||||
*/
|
||||
private static function executeHandlers(
|
||||
array $registry,
|
||||
string $registryType,
|
||||
string|object $event,
|
||||
mixed $payload,
|
||||
array|object $available_params = []
|
||||
): mixed {
|
||||
|
||||
$isEvent = ($registryType === 'events');
|
||||
$filteredPayload = null;
|
||||
$index = 0;
|
||||
|
||||
try {
|
||||
// sort matches by priority
|
||||
usort($registry, fn ($a, $b) => match (true) {
|
||||
$a['priority'] > $b['priority'] => 1,
|
||||
$a['priority'] == $b['priority'] => 0,
|
||||
default => -1,
|
||||
});
|
||||
|
||||
foreach ($registry as $index => $listener) {
|
||||
|
||||
$handler = $listener['listener'];
|
||||
|
||||
// Part 1: Handle Events
|
||||
if ($isEvent) {
|
||||
|
||||
// parsing listener to determine whether we;re dealing with a closure, class, object, string etc
|
||||
$parsedListener = self::makeListener($handler);
|
||||
|
||||
if ($listener['source'] == 'laravel') {
|
||||
$parsedListener($event, $payload['laravel']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedListener($event, [$payload['leantime']]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Part 2: Handle Filters
|
||||
if ($index === 0) {
|
||||
$filteredPayload = $payload;
|
||||
}
|
||||
|
||||
$filteredPayload = $handler($filteredPayload, $available_params);
|
||||
|
||||
continue;
|
||||
|
||||
// // Handle Laravel style events
|
||||
// //payload has an actual object
|
||||
// //Those will never be filters
|
||||
// if (self::isLaravelEvent($payload)) {
|
||||
// self::handleLaravelEvent($handler, $payload[0]);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
//
|
||||
// if (self::isHandleableObject($handler)) {
|
||||
// self::handleLaravelEvent($handler, $payload[0]);
|
||||
// }
|
||||
//
|
||||
// // Handle class with handle method
|
||||
// if (self::isHandleableClass($handler)) {
|
||||
//
|
||||
// if ($isEvent) {
|
||||
//
|
||||
// $handler->handle($payload);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// $filteredPayload = $handler->handle(
|
||||
// $index == 0 ? $payload : $filteredPayload,
|
||||
// $available_params
|
||||
// );
|
||||
//
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// // Handle Closures and callable functions
|
||||
// if (is_callable($handler)) {
|
||||
//
|
||||
// if ($isEvent) {
|
||||
// self::executeCallable($handler, $payload, $available_params, $index, $isEvent);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// $result = self::executeCallable($handler, $index == 0 ? $payload : $filteredPayload, $available_params, $index, $isEvent);
|
||||
// if ($result !== null) {
|
||||
// $filteredPayload = $result;
|
||||
// }
|
||||
// continue;
|
||||
// }
|
||||
|
||||
}
|
||||
} catch (\TypeError $e) {
|
||||
|
||||
if (! isset($filteredPayload) && $index === 0) {
|
||||
$filteredPayload = $payload;
|
||||
}
|
||||
|
||||
Log::error($e);
|
||||
}
|
||||
|
||||
return $isEvent ? null : $filteredPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all the event and filter listeners and registers them
|
||||
* (should only be executed once at the beginning of the program)
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function discoverListeners(): void
|
||||
{
|
||||
static $discovered;
|
||||
$discovered ??= false;
|
||||
|
||||
if ($discovered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((bool) config('debug') === false) {
|
||||
|
||||
$modules = Cache::store('installation')->rememberForever('domainEvents', function () {
|
||||
return EventDispatcher::getDomainPaths();
|
||||
});
|
||||
|
||||
} else {
|
||||
$modules = self::getDomainPaths();
|
||||
}
|
||||
|
||||
foreach ($modules as $module) {
|
||||
if (file_exists($moduleEventsPath = "$module/register.php")) {
|
||||
include_once $moduleEventsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Call system plugins (defined via config)
|
||||
if (isset(app(Environment::class)->plugins)) {
|
||||
$configplugins = explode(',', app(Environment::class)->plugins);
|
||||
|
||||
// TODO: Do phar plugins get to be system plugins? Right now they dont
|
||||
foreach ($configplugins as $plugin) {
|
||||
if (file_exists($pluginEventsPath = APP_ROOT.'/app/Plugins/'.$plugin.'/register.php')) {
|
||||
include_once $pluginEventsPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load routes.php files from domains and system plugins
|
||||
// User plugin routes will be loaded via event after plugins are enabled
|
||||
RouteLoader::loadRoutes();
|
||||
|
||||
EventDispatcher::add_event_listener('leantime.core.middleware.loadplugins.handle.pluginsStart', function () {
|
||||
|
||||
if (! session('isInstalled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pluginPath = APP_ROOT.'/app/Plugins/';
|
||||
$pluginService = app()->make(\Leantime\Domain\Plugins\Services\Plugins::class);
|
||||
$enabledPlugins = $pluginService->getEnabledPlugins();
|
||||
|
||||
foreach ($enabledPlugins as $plugin) {
|
||||
|
||||
// Catch issue when plugins are cached on load but autoloader is not quite done loading.
|
||||
// Only happens because the plugin objects are stored in session and the unserialize is not keeping up.
|
||||
// Clearing session cache in that case.
|
||||
// @TODO: Check on callstack to make sure autoload loads before sessions
|
||||
if (is_a($plugin, '__PHP_Incomplete_Class')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($plugin == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($plugin->format == 'phar') {
|
||||
$pharPath = "phar://{$pluginPath}{$plugin->foldername}/{$plugin->foldername}.phar";
|
||||
|
||||
if (! file_exists($pharPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
include_once $pharPath;
|
||||
|
||||
if (! file_exists("$pharPath/register.php")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
include_once "$pharPath/register.php";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! file_exists($registerPath = "{$pluginPath}{$plugin->foldername}/register.php")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
include_once $registerPath;
|
||||
}
|
||||
});
|
||||
|
||||
$discovered = true;
|
||||
}
|
||||
|
||||
public static function getDomainPaths()
|
||||
{
|
||||
return collect(glob(APP_ROOT.'/app/Domain'.'/*', GLOB_ONLYDIR))->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener to be registered
|
||||
*/
|
||||
public static function add_event_listener(
|
||||
$event,
|
||||
$listener,
|
||||
int $priority = 10,
|
||||
$listenerSource = 'leantime'
|
||||
): void {
|
||||
|
||||
// Some backwards compatibility rules
|
||||
if (str_starts_with($event, 'leantime.core.template.tpl')) {
|
||||
$eventParts = explode('.', $event);
|
||||
|
||||
$count = count($eventParts);
|
||||
|
||||
$event = 'leantime.*.'.($eventParts[$count - 2] ?? '').'.'.($eventParts[$count - 1] ?? '');
|
||||
}
|
||||
|
||||
if ($event == 'leantime.core.*.afterFooterOpen') {
|
||||
$event = 'leantime.*.afterFooterOpen';
|
||||
}
|
||||
|
||||
if (! array_key_exists($event, self::$eventRegistry)) {
|
||||
self::$eventRegistry[$event] = [];
|
||||
}
|
||||
|
||||
// Laravel adds the listener directly without having priority. Keep that in mind!!
|
||||
self::$eventRegistry[$event][] = ['listener' => $listener, 'priority' => $priority, 'source' => $listenerSource];
|
||||
self::$eventRegistryVersion++;
|
||||
}
|
||||
|
||||
public static function addEventListener($event, $listener, $priority = 10, $source = 'leantime')
|
||||
{
|
||||
self::add_event_listener($event, $listener, $priority, $source);
|
||||
}
|
||||
|
||||
public static function add_filter_listener(
|
||||
$filtername,
|
||||
$listener,
|
||||
int $priority = 10,
|
||||
$listenerSource = 'leantime'
|
||||
): void {
|
||||
if (! array_key_exists($filtername, self::$filterRegistry)) {
|
||||
self::$filterRegistry[$filtername] = [];
|
||||
}
|
||||
self::$filterRegistry[$filtername][] = ['listener' => $listener, 'priority' => $priority, 'source' => $listenerSource];
|
||||
self::$filterRegistryVersion++;
|
||||
}
|
||||
|
||||
public static function addFilterListener(
|
||||
$filtername,
|
||||
$listener,
|
||||
int $priority = 10
|
||||
): void {
|
||||
self::add_filter_listener($filtername, $listener, $priority);
|
||||
}
|
||||
|
||||
// Laravel listen. They can do whatever.
|
||||
public function listen($events, $listener = null)
|
||||
{
|
||||
|
||||
if ($events instanceof \Closure) {
|
||||
collect($this->firstClosureParameterTypes($events))
|
||||
->each(function ($event) use ($events) {
|
||||
$this->listen($event, $events);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ((array) $events as $event) {
|
||||
$this->add_event_listener($event, $listener, 10, 'laravel');
|
||||
}
|
||||
}
|
||||
|
||||
// Different options for events and listeners
|
||||
|
||||
// Event itself is object
|
||||
// Event itself is class string
|
||||
// Event itself is just string
|
||||
|
||||
// Listener options
|
||||
|
||||
// 2 Listener is closure
|
||||
// 3 Listener is callable (array)
|
||||
// 4 Listener is class string (call handle)
|
||||
/**
|
||||
* Register an event listener with the dispatcher.
|
||||
*
|
||||
* @param \Closure|string|array $listener
|
||||
* @param bool $wildcard
|
||||
* @return \Closure
|
||||
*/
|
||||
public static function makeListener($listener, $wildcard = false)
|
||||
{
|
||||
|
||||
if (is_string($listener) && ! function_exists($listener)) {
|
||||
return self::createClassListener($listener, $wildcard);
|
||||
}
|
||||
|
||||
if (is_array($listener) && isset($listener[0]) && is_string($listener[0])) {
|
||||
return self::createClassListener($listener, $wildcard);
|
||||
}
|
||||
|
||||
// If listener is a closure, we're preparing a closure to call the closure...
|
||||
return function ($event, $payload) use ($listener, $wildcard) {
|
||||
if ($wildcard) {
|
||||
return $listener($event, $payload);
|
||||
}
|
||||
|
||||
return $listener(...array_values($payload));
|
||||
};
|
||||
}
|
||||
|
||||
public static function createClassListener($listener, $wildcard = false)
|
||||
{
|
||||
return function ($event, $payload) use ($listener, $wildcard) {
|
||||
if ($wildcard) {
|
||||
return call_user_func(self::createClassCallable($listener), $event, $payload);
|
||||
}
|
||||
|
||||
$callable = self::createClassCallable($listener);
|
||||
|
||||
return $callable(...array_values($payload));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the class based event callable.
|
||||
* Covers options 3+4
|
||||
*
|
||||
* @param array|string $listener
|
||||
* @return callable
|
||||
*/
|
||||
protected static function createClassCallable($listener)
|
||||
{
|
||||
[$class, $method] = is_array($listener)
|
||||
? $listener
|
||||
: self::parseClassCallable($listener);
|
||||
|
||||
if (! method_exists($class, $method)) {
|
||||
$method = '__invoke';
|
||||
}
|
||||
|
||||
// if ($this->handlerShouldBeQueued($class)) {
|
||||
// return $this->createQueuedHandlerCallable($class, $method);
|
||||
// }
|
||||
|
||||
$listener = app()->make($class);
|
||||
|
||||
// return $this->handlerShouldBeDispatchedAfterDatabaseTransactions($listener)
|
||||
// ? $this->createCallbackForListenerRunningAfterCommits($listener, $method)
|
||||
// : [$listener, $method];
|
||||
|
||||
return [$listener, $method];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the class listener into class and method.
|
||||
*
|
||||
* @param string $listener
|
||||
* @return array
|
||||
*/
|
||||
protected static function parseClassCallable($listener)
|
||||
{
|
||||
return Str::parseCallback($listener, 'handle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all registered listeners
|
||||
*/
|
||||
public static function get_registries(): array
|
||||
{
|
||||
return [
|
||||
'events' => array_keys(self::$eventRegistry),
|
||||
'filters' => array_keys(self::$filterRegistry),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all available hooks
|
||||
*/
|
||||
public static function get_available_hooks(): array
|
||||
{
|
||||
return self::$available_hooks;
|
||||
}
|
||||
|
||||
public static function getEventRegistry(): array
|
||||
{
|
||||
return self::$eventRegistry;
|
||||
}
|
||||
|
||||
public static function getFilterRegistry(): array
|
||||
{
|
||||
return self::$filterRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a given event has listeners.
|
||||
*
|
||||
* @param string $eventName
|
||||
* @return bool
|
||||
*/
|
||||
public function hasListeners($eventName)
|
||||
{
|
||||
return array_key_exists($eventName, self::$eventRegistry);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event subscriber with the dispatcher.
|
||||
*
|
||||
* @param object|string $subscriber
|
||||
* @return void
|
||||
*/
|
||||
public function subscribe($subscriber) {}
|
||||
|
||||
/**
|
||||
* Dispatch an event until the first non-null response is returned.
|
||||
*
|
||||
* @param string|object $event
|
||||
* @param mixed $payload
|
||||
* @return mixed
|
||||
*/
|
||||
public function until($event, $payload = [])
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the listeners for a given event name.
|
||||
*
|
||||
* @param string $eventName
|
||||
* @return array
|
||||
*/
|
||||
public function getListeners($eventName)
|
||||
{
|
||||
$listeners = $this->findEventListeners($eventName, $this->getEventRegistry());
|
||||
$list = array_map(fn ($item) => $item['listener'], $listeners);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event and payload to be fired later.
|
||||
*
|
||||
* @param string $event
|
||||
* @param array $payload
|
||||
* @return void
|
||||
*/
|
||||
public function push($event, $payload = [])
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a set of pushed events.
|
||||
*
|
||||
* @param string $event
|
||||
* @return void
|
||||
*/
|
||||
public function flush($event)
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a set of listeners from the dispatcher.
|
||||
*
|
||||
* @param string $event
|
||||
* @return void
|
||||
*/
|
||||
public function forget($event)
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget all of the queued listeners.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function forgetPushed()
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
}
|
||||
36
app/Core/Events/EventVerb.php
Normal file
36
app/Core/Events/EventVerb.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events;
|
||||
|
||||
/**
|
||||
* The central verb vocabulary for class-based domain events.
|
||||
*
|
||||
* Event classes are named {Entity}{Verb} (TicketCreated, MilestoneDeleted) and the verb
|
||||
* MUST be a case of this enum — one vocabulary across all domains, mirroring the client
|
||||
* (HTMX) convention lt:{domain}:{entity}.{verb} and the permission vocabulary
|
||||
* {domain}.{action}. Synonyms are deliberately rejected: it is always Updated, never
|
||||
* Changed/Edited/Saved/Modified. Add a case here only when no existing verb fits.
|
||||
*
|
||||
* All verbs are past tense: events report state changes that already happened.
|
||||
*/
|
||||
enum EventVerb: string
|
||||
{
|
||||
case Created = 'created';
|
||||
case Updated = 'updated';
|
||||
case Deleted = 'deleted';
|
||||
case Added = 'added';
|
||||
case Removed = 'removed';
|
||||
case Moved = 'moved';
|
||||
case Completed = 'completed';
|
||||
case Started = 'started';
|
||||
case Succeeded = 'succeeded';
|
||||
case Failed = 'failed';
|
||||
case Archived = 'archived';
|
||||
case Restored = 'restored';
|
||||
case Duplicated = 'duplicated';
|
||||
case Uploaded = 'uploaded';
|
||||
case Sent = 'sent';
|
||||
case Notified = 'notified';
|
||||
case Registered = 'registered';
|
||||
case Initialized = 'initialized';
|
||||
}
|
||||
47
app/Core/Events/EventsServiceProvider.php
Normal file
47
app/Core/Events/EventsServiceProvider.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Leantime\Core;
|
||||
|
||||
class EventsServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
|
||||
$this->app->singleton('events', function ($app) {
|
||||
return new Core\Events\EventDispatcher;
|
||||
});
|
||||
|
||||
$this->booting(function () {
|
||||
|
||||
// Core\Events\EventDispatcher::discover_listeners();
|
||||
|
||||
/*
|
||||
|
||||
foreach ($this->subscribe as $subscriber) {
|
||||
Event::subscribe($subscriber);
|
||||
}
|
||||
|
||||
foreach ($this->observers as $model => $observers) {
|
||||
$model::observe($observers);
|
||||
}*/
|
||||
|
||||
});
|
||||
|
||||
/*
|
||||
$this->booted(function () {
|
||||
$this->configureEmailVerification();
|
||||
});
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
public function boot() {}
|
||||
}
|
||||
38
app/Core/Events/Htmx/HtmxEvent.php
Normal file
38
app/Core/Events/Htmx/HtmxEvent.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Htmx;
|
||||
|
||||
/**
|
||||
* Contract for client (HTMX) event enums.
|
||||
*
|
||||
* Client events travel from the server to the browser on the `HX-Trigger` response header and are
|
||||
* consumed either declaratively (`hx-trigger="<event> from:body"`) for data events, or by a JS
|
||||
* listener for UI command events. Implementations are string-backed enums whose backing value IS
|
||||
* the wire name, following the convention:
|
||||
*
|
||||
* - data events: lt:{domain}:{entity}.{verb} e.g. lt:tickets:ticket.updated
|
||||
* - UI commands: lt:ui:{command} e.g. lt:ui:modal.close
|
||||
*
|
||||
* Use the {@see InteractsWithHtmxEvents} trait to satisfy this interface. Note: PHP enums cannot
|
||||
* implement Stringable / __toString, so use {@see event()} (or `->value`) to get the wire name in
|
||||
* PHP. In Blade, `{{ MyEvents::Case }}` renders the value (Laravel's e() unwraps backed enums).
|
||||
*/
|
||||
interface HtmxEvent
|
||||
{
|
||||
/**
|
||||
* The wire name (the enum's backing value), e.g. "lt:tickets:ticket.updated".
|
||||
*/
|
||||
public function event(): string;
|
||||
|
||||
/**
|
||||
* Wire name scoped to a single entity, e.g. "lt:reactions:sentiment.updated#42".
|
||||
* Lets a specific component listen for changes to one entity while broad listeners use the
|
||||
* unscoped name.
|
||||
*/
|
||||
public function scoped(int|string $id): string;
|
||||
|
||||
/**
|
||||
* The value formatted for an `hx-trigger` attribute, e.g. "lt:tickets:ticket.updated from:body".
|
||||
*/
|
||||
public function trigger(string $from = 'body'): string;
|
||||
}
|
||||
68
app/Core/Events/Htmx/HtmxEvents.php
Normal file
68
app/Core/Events/Htmx/HtmxEvents.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Htmx;
|
||||
|
||||
/**
|
||||
* Helpers for building the `HX-Trigger` response header from client event names.
|
||||
*
|
||||
* MIGRATION WINDOW: while emitters and listeners move to the lt:{domain}:{entity}.{verb} convention
|
||||
* we dual-emit legacy names alongside their canonical replacement so existing declarative listeners
|
||||
* (hx-trigger="<name> from:body") and (phar) plugins keep working without coordinated releases. Each
|
||||
* group is bidirectional — emitting ANY member puts the whole group on the wire, so old and new
|
||||
* listeners both fire regardless of which name the emitter used. Delete LEGACY_ALIASES once every
|
||||
* emitter and listener has been migrated.
|
||||
*
|
||||
* Only DOMAIN data events are aliased here. UI command events (lt:ui:*) are intentionally NOT
|
||||
* aliased: they're consumed by JS addEventListener handlers that listen for each name directly, so
|
||||
* dual-emitting them would fire the same handler once per alias (double growl, multiple modal-close
|
||||
* callbacks) for a single response.
|
||||
*/
|
||||
final class HtmxEvents
|
||||
{
|
||||
/**
|
||||
* Bidirectional alias groups. The first entry of each group is the canonical lt:* name.
|
||||
*
|
||||
* @var array<int, array<int, string>>
|
||||
*/
|
||||
private const LEGACY_ALIASES = [
|
||||
['lt:tickets:ticket.updated', 'ticket_update'],
|
||||
['lt:tickets:subtask.updated', 'subtasks_update', 'subtasksUpdated'],
|
||||
['lt:projects:project.updated', 'HTMX.updateProjectList'],
|
||||
['lt:timesheets:timer.updated', 'timerUpdate'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Expand event names to include their legacy/canonical aliases, de-duplicated and order-stable.
|
||||
* Scoped names (e.g. "lt:reactions:sentiment.updated#42") pass through unchanged.
|
||||
*
|
||||
* @param array<int, string|HtmxEvent> $names
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function expand(array $names): array
|
||||
{
|
||||
$expanded = [];
|
||||
|
||||
foreach ($names as $name) {
|
||||
$name = $name instanceof HtmxEvent ? $name->event() : (string) $name;
|
||||
$expanded[] = $name;
|
||||
|
||||
foreach (self::LEGACY_ALIASES as $group) {
|
||||
if (in_array($name, $group, true)) {
|
||||
array_push($expanded, ...$group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the comma-separated `HX-Trigger` header value from queued event names.
|
||||
*
|
||||
* @param array<int, string|HtmxEvent> $names
|
||||
*/
|
||||
public static function triggerHeader(array $names): string
|
||||
{
|
||||
return implode(',', self::expand($names));
|
||||
}
|
||||
}
|
||||
31
app/Core/Events/Htmx/HtmxUiEvents.php
Normal file
31
app/Core/Events/Htmx/HtmxUiEvents.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Htmx;
|
||||
|
||||
/**
|
||||
* Canonical client UI command events (the `lt:ui:*` plane).
|
||||
*
|
||||
* These are imperative commands to the JS layer (show a toast, close the modal, refresh the page),
|
||||
* as opposed to domain data events ("entity X changed") which live in per-domain Htmx{Domain}Events
|
||||
* enums. There is exactly one home for UI commands so casing/naming never drifts again — plugins
|
||||
* reuse these cases rather than minting their own `lt:ui:*` strings.
|
||||
*
|
||||
* Legacy string equivalents (e.g. HTMX.ShowNotification, closeModal) are dual-emitted during the
|
||||
* migration window via {@see HtmxEvents::expand()} so existing listeners keep working.
|
||||
*/
|
||||
enum HtmxUiEvents: string implements HtmxEvent
|
||||
{
|
||||
use InteractsWithHtmxEvents;
|
||||
|
||||
/** Fetch + show the latest growl notification. Replaces 'HTMX.ShowNotification'. */
|
||||
case Notify = 'lt:ui:notify';
|
||||
|
||||
/** Close the top-most modal. Replaces 'closeModal' / 'HTMX.closemodal' / 'Htmx.CloseModal'. */
|
||||
case ModalClose = 'lt:ui:modal.close';
|
||||
|
||||
/** Open a modal for the current url hash. */
|
||||
case ModalOpen = 'lt:ui:modal.open';
|
||||
|
||||
/** Refresh the main page url in the background. */
|
||||
case UrlRefresh = 'lt:ui:url.refresh';
|
||||
}
|
||||
37
app/Core/Events/Htmx/InteractsWithHtmxEvents.php
Normal file
37
app/Core/Events/Htmx/InteractsWithHtmxEvents.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Htmx;
|
||||
|
||||
/**
|
||||
* Shared behavior for string-backed client (HTMX) event enums.
|
||||
*
|
||||
* The backing value of each case is the wire name. PHP enums cannot define __toString, so use
|
||||
* {@see event()} to obtain the wire name in PHP code; Blade's `{{ }}` renders the value directly
|
||||
* because Laravel's e() helper unwraps backed enums.
|
||||
*/
|
||||
trait InteractsWithHtmxEvents
|
||||
{
|
||||
/**
|
||||
* The wire name (the enum's backing value).
|
||||
*/
|
||||
public function event(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire name scoped to a single entity id, e.g. "lt:reactions:sentiment.updated#42".
|
||||
*/
|
||||
public function scoped(int|string $id): string
|
||||
{
|
||||
return $this->value.'#'.$id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format for an `hx-trigger` attribute, e.g. "lt:tickets:ticket.updated from:body".
|
||||
*/
|
||||
public function trigger(string $from = 'body'): string
|
||||
{
|
||||
return $this->value.($from !== '' ? ' from:'.$from : '');
|
||||
}
|
||||
}
|
||||
29
app/Core/Exceptions/AuthException.php
Normal file
29
app/Core/Exceptions/AuthException.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @deprecated Use {@see AuthorizationException} instead.
|
||||
*
|
||||
* Thin, deprecated alias of {@see AuthorizationException} (HTTP 403 / JSON-RPC -32001), kept
|
||||
* only because the AdvancedAuth plugin — and potentially external installs — still throw this
|
||||
* class name (e.g. AdvancedAuth\Listeners\CheckDomain). It carries no behaviour of its own
|
||||
* beyond preserving the legacy ($message, $code) constructor signature, so it is NOT a second
|
||||
* authorization exception — it IS an AuthorizationException.
|
||||
*/
|
||||
class AuthException extends AuthorizationException
|
||||
{
|
||||
/**
|
||||
* @param string $message The exception message.
|
||||
* @param int $code HTTP status, also exposed via getStatusCode() and getCode() (default 403).
|
||||
* @param Throwable|null $previous Previous throwable for chaining.
|
||||
*/
|
||||
public function __construct(string $message = '', int $code = 403, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $previous);
|
||||
$this->statusCode = $code;
|
||||
$this->code = $code;
|
||||
}
|
||||
}
|
||||
27
app/Core/Exceptions/AuthorizationException.php
Normal file
27
app/Core/Exceptions/AuthorizationException.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Thrown when an authenticated user is not allowed to perform an action or access a resource.
|
||||
*
|
||||
* Renders as HTTP 403 on the web/REST surfaces and JSON-RPC error -32001 (an
|
||||
* implementation-defined code in the reserved -32000..-32099 range) on /api/jsonrpc.
|
||||
*
|
||||
* Services should throw this instead of returning `false`/`[]` on an authorization failure,
|
||||
* so the denial is unambiguous (today `return []`/`return false` collide with "no results"
|
||||
* and "not found").
|
||||
*/
|
||||
class AuthorizationException extends LeantimeException
|
||||
{
|
||||
protected int $statusCode = 403;
|
||||
|
||||
protected int $rpcCode = -32001;
|
||||
|
||||
public function __construct(string $message = 'You are not allowed to perform this action.', ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
}
|
||||
48
app/Core/Exceptions/Contracts/LeantimeExceptionInterface.php
Normal file
48
app/Core/Exceptions/Contracts/LeantimeExceptionInterface.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions\Contracts;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
/**
|
||||
* Contract for Leantime's first-class domain exceptions.
|
||||
*
|
||||
* The design principle: exceptions carry *semantics*; each entry point owns *format*.
|
||||
* A single thrown exception must render correctly across all of Leantime's surfaces —
|
||||
* the JSON-RPC endpoint, the web/HTMX controllers (via the global ExceptionHandler), and
|
||||
* any future REST surface — so the exception declares what it *means* and lets each
|
||||
* surface decide how to present it:
|
||||
*
|
||||
* - getStatusCode() : the HTTP status for the web/REST surfaces. By extending Symfony's
|
||||
* HttpExceptionInterface, the global ExceptionHandler honors this with
|
||||
* no special-casing (its isHttpException() check already keys off it).
|
||||
* - getRpcCode() : the JSON-RPC 2.0 error code for the /api/jsonrpc surface
|
||||
* (JsonRpcErrorResponse::fromException reads it).
|
||||
* - getClientMessage(): a message that is safe to expose to a client. getMessage() stays
|
||||
* internal/loggable; this is the curated, user-facing sentence.
|
||||
* - getErrorData() : optional structured detail (e.g. a field => [messages] map for
|
||||
* validation), serialized into the JSON-RPC error `data` member.
|
||||
*
|
||||
* @see \Leantime\Core\Exceptions\LeantimeException The abstract base implementing this.
|
||||
* @see \Leantime\Core\Http\Responses\JsonRpcErrorResponse::fromException()
|
||||
*/
|
||||
interface LeantimeExceptionInterface extends HttpExceptionInterface
|
||||
{
|
||||
/**
|
||||
* JSON-RPC 2.0 error code for this failure (e.g. -32602 invalid params).
|
||||
*/
|
||||
public function getRpcCode(): int;
|
||||
|
||||
/**
|
||||
* A client-safe description of the failure. Distinct from getMessage(), which may
|
||||
* contain internal detail that should only be logged.
|
||||
*/
|
||||
public function getClientMessage(): string;
|
||||
|
||||
/**
|
||||
* Optional structured detail (e.g. ['field' => ['message', ...]]); empty when none.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getErrorData(): array;
|
||||
}
|
||||
26
app/Core/Exceptions/EntityExistsException.php
Normal file
26
app/Core/Exceptions/EntityExistsException.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* An entity being created already exists — a conflict (HTTP 409 / JSON-RPC -32005).
|
||||
*
|
||||
* A {@see LeantimeException} so the 409 status is honored across all surfaces.
|
||||
*/
|
||||
class EntityExistsException extends LeantimeException
|
||||
{
|
||||
protected int $rpcCode = -32005;
|
||||
|
||||
/**
|
||||
* @param string $message The exception message.
|
||||
* @param int $code HTTP status, also exposed via getStatusCode() and getCode().
|
||||
* @param Throwable|null $previous Previous throwable for chaining.
|
||||
*/
|
||||
public function __construct(string $message = '', int $code = 409, ?Throwable $previous = null)
|
||||
{
|
||||
$this->statusCode = $code;
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
592
app/Core/Exceptions/ExceptionHandler.php
Normal file
592
app/Core/Exceptions/ExceptionHandler.php
Normal file
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract;
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Session\TokenMismatchException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Reflector;
|
||||
use Illuminate\Support\Traits\ReflectsClosures;
|
||||
use InvalidArgumentException;
|
||||
use Leantime\Core\Application;
|
||||
use Leantime\Core\Exceptions\Contracts\LeantimeExceptionInterface;
|
||||
use Leantime\Core\Http\ApiRequest;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Sentry\Laravel\Integration;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer;
|
||||
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Throwable;
|
||||
use Whoops\Handler\HandlerInterface;
|
||||
use Whoops\Run as Whoops;
|
||||
|
||||
class ExceptionHandler implements ExceptionHandlerContract
|
||||
{
|
||||
use ReflectsClosures;
|
||||
|
||||
/**
|
||||
* The container implementation.
|
||||
*/
|
||||
protected Application $container;
|
||||
|
||||
/**
|
||||
* A list of the exception types that are not reported.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $dontReport = [];
|
||||
|
||||
/**
|
||||
* The callbacks that should be used during reporting.
|
||||
*
|
||||
* @var ReportableHandler[]
|
||||
*/
|
||||
protected $reportCallbacks = [];
|
||||
|
||||
/**
|
||||
* The callbacks that should be used during rendering.
|
||||
*
|
||||
* @var \Closure[]
|
||||
*/
|
||||
protected $renderCallbacks = [];
|
||||
|
||||
/**
|
||||
* The registered exception mappings.
|
||||
*
|
||||
* @var array<string, \Closure>
|
||||
*/
|
||||
protected $exceptionMap = [];
|
||||
|
||||
/**
|
||||
* A list of the internal exception types that should not be reported.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $internalDontReport = [
|
||||
HttpException::class,
|
||||
HttpResponseException::class,
|
||||
SuspiciousOperationException::class,
|
||||
TokenMismatchException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* A list of the inputs that are never flashed for validation exceptions.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $dontFlash = [
|
||||
'current_password',
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a new exception handler instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Application $container)
|
||||
{
|
||||
$this->container = $container;
|
||||
|
||||
$this->register();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the exception handling callbacks for the application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
Integration::captureUnhandledException($e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a reportable callback.
|
||||
*
|
||||
* @return \Leantime\Core\Exceptions\ReportableHandler
|
||||
*/
|
||||
public function reportable(callable $reportUsing)
|
||||
{
|
||||
if (! $reportUsing instanceof Closure) {
|
||||
$reportUsing = Closure::fromCallable($reportUsing);
|
||||
}
|
||||
|
||||
return tap(new ReportableHandler($reportUsing), function ($callback) {
|
||||
$this->reportCallbacks[] = $callback;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a renderable callback.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function renderable(callable $renderUsing)
|
||||
{
|
||||
if (! $renderUsing instanceof Closure) {
|
||||
$renderUsing = Closure::fromCallable($renderUsing);
|
||||
}
|
||||
|
||||
$this->renderCallbacks[] = $renderUsing;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new exception mapping.
|
||||
*
|
||||
* @param \Closure|string $from
|
||||
* @param \Closure|string|null $to
|
||||
* @return $this
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function map($from, $to = null)
|
||||
{
|
||||
if (is_string($to)) {
|
||||
$to = function ($exception) use ($to) {
|
||||
return new $to('', 0, $exception);
|
||||
};
|
||||
}
|
||||
|
||||
if (is_callable($from) && is_null($to)) {
|
||||
$from = $this->firstClosureParameterType($to = $from);
|
||||
}
|
||||
|
||||
if (! is_string($from) || ! $to instanceof Closure) {
|
||||
throw new InvalidArgumentException('Invalid exception mapping.');
|
||||
}
|
||||
|
||||
$this->exceptionMap[$from] = $to;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the given exception type should not be reported.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function ignore(string $class)
|
||||
{
|
||||
$this->dontReport[] = $class;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report or log an exception.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function report(Throwable $e)
|
||||
{
|
||||
$e = $this->mapException($e);
|
||||
|
||||
if ($this->shouldntReport($e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Reflector::isCallable($reportCallable = [$e, 'report'])) {
|
||||
if ($this->container->call($reportCallable) !== false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->reportCallbacks as $reportCallback) {
|
||||
if ($reportCallback->handles($e)) {
|
||||
if ($reportCallback($e) === false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$logger = app(LoggerInterface::class);
|
||||
} catch (Exception $ex) {
|
||||
throw $e; // throw the original exception
|
||||
}
|
||||
|
||||
$logger->error($e->getMessage(), ['exception' => $e]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the exception should be reported.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldReport(Throwable $e)
|
||||
{
|
||||
return ! $this->shouldntReport($e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the exception is in the "do not report" list.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function shouldntReport(Throwable $e)
|
||||
{
|
||||
$dontReport = array_merge($this->dontReport, $this->internalDontReport);
|
||||
|
||||
return ! is_null(Arr::first($dontReport, function ($type) use ($e) {
|
||||
return $e instanceof $type;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default exception context variables for logging.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function exceptionContext(Throwable $e)
|
||||
{
|
||||
if (method_exists($e, 'context')) {
|
||||
return $e->context();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default context variables for logging.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function context()
|
||||
{
|
||||
try {
|
||||
return array_filter([
|
||||
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an exception into an HTTP response.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function render($request, Throwable $e)
|
||||
{
|
||||
if (method_exists($e, 'render') && $response = $e->render($request)) {
|
||||
return $response;
|
||||
} elseif ($e instanceof Responsable) {
|
||||
return $e->toResponse($request);
|
||||
}
|
||||
|
||||
$e = $this->prepareException($this->mapException($e));
|
||||
|
||||
foreach ($this->renderCallbacks as $renderCallback) {
|
||||
foreach ($this->firstClosureParameterTypes($renderCallback) as $type) {
|
||||
if (is_a($e, $type)) {
|
||||
$response = $renderCallback($e, $request);
|
||||
|
||||
if (! is_null($response)) {
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($e instanceof HttpResponseException) {
|
||||
return $e->getResponse();
|
||||
}
|
||||
|
||||
return $this->shouldReturnJson($request, $e)
|
||||
? $this->prepareJsonResponse($request, $e)
|
||||
: $this->prepareResponse($request, $e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the exception using a registered mapper if possible.
|
||||
*
|
||||
* @return \Throwable
|
||||
*/
|
||||
protected function mapException(Throwable $e)
|
||||
{
|
||||
foreach ($this->exceptionMap as $class => $mapper) {
|
||||
if (is_a($e, $class)) {
|
||||
return $mapper($e);
|
||||
}
|
||||
}
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare exception for rendering.
|
||||
*
|
||||
* @return \Throwable
|
||||
*/
|
||||
protected function prepareException(Throwable $e)
|
||||
{
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the exception handler response should be JSON.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return bool
|
||||
*/
|
||||
protected function shouldReturnJson($request, Throwable $e)
|
||||
{
|
||||
// API requests (x-api-key / bearer) are JSON by contract even when the client omits an
|
||||
// Accept header, so they get a JSON error body instead of an HTML error page.
|
||||
return $request instanceof ApiRequest || $request->expectsJson();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a response for the given exception.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
protected function prepareResponse($request, Throwable $e)
|
||||
{
|
||||
if (! $this->isHttpException($e) && config('debug')) {
|
||||
return $this->toIlluminateResponse($this->convertExceptionToResponse($e), $e);
|
||||
}
|
||||
|
||||
if (! $this->isHttpException($e)) {
|
||||
$e = new HttpException(500, $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->toIlluminateResponse(
|
||||
$this->renderHttpException($e),
|
||||
$e
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Symfony response for the given exception.
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
protected function convertExceptionToResponse(Throwable $e)
|
||||
{
|
||||
return new SymfonyResponse(
|
||||
$this->renderExceptionContent($e),
|
||||
$this->isHttpException($e) ? $e->getStatusCode() : 500,
|
||||
$this->isHttpException($e) ? $e->getHeaders() : []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response content for the given exception.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function renderExceptionContent(Throwable $e)
|
||||
{
|
||||
try {
|
||||
return config('debug') && class_exists(Whoops::class)
|
||||
? $this->renderExceptionWithWhoops($e)
|
||||
: $this->renderExceptionWithSymfony($e, config('debug'));
|
||||
} catch (Exception $e) {
|
||||
return $this->renderExceptionWithSymfony($e, config('debug'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an exception to a string using "Whoops".
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function renderExceptionWithWhoops(Throwable $e)
|
||||
{
|
||||
return tap(new Whoops, function ($whoops) {
|
||||
$whoops->appendHandler($this->whoopsHandler());
|
||||
|
||||
$whoops->writeToOutput(false);
|
||||
|
||||
$whoops->allowQuit(false);
|
||||
})->handleException($e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Whoops handler for the application.
|
||||
*
|
||||
* @return \Whoops\Handler\HandlerInterface
|
||||
*/
|
||||
protected function whoopsHandler()
|
||||
{
|
||||
try {
|
||||
return app(HandlerInterface::class);
|
||||
} catch (BindingResolutionException $e) {
|
||||
return (new WhoopsHandler)->forDebug();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an exception to a string using Symfony.
|
||||
*
|
||||
* @param bool $debug
|
||||
* @return string
|
||||
*/
|
||||
protected function renderExceptionWithSymfony(Throwable $e, $debug)
|
||||
{
|
||||
$renderer = new HtmlErrorRenderer($debug);
|
||||
|
||||
return $renderer->render($e)->getAsString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the given HttpException.
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
protected function renderHttpException(HttpExceptionInterface $e)
|
||||
{
|
||||
|
||||
try {
|
||||
$view = $this->getHttpExceptionView($e);
|
||||
|
||||
return app()->make(Template::class)->display($view, 'error', $e->getStatusCode());
|
||||
} catch (Throwable $e) {
|
||||
return $this->convertExceptionToResponse($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the error template hint paths.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerErrorViewPaths() {}
|
||||
|
||||
/**
|
||||
* Get the view used to render HTTP exceptions.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getHttpExceptionView(HttpExceptionInterface $e)
|
||||
{
|
||||
$status = $e->getStatusCode();
|
||||
|
||||
// Dedicated error pages exist only for these statuses. Anything else (e.g. a 422 from a
|
||||
// ValidationException or a 409 from EntityExistsException — now that typed exceptions
|
||||
// carry real HTTP statuses) falls back to the generic 500 page instead of throwing a
|
||||
// view-not-found that degrades to a raw Symfony error page.
|
||||
return in_array($status, [403, 404, 500, 501], true)
|
||||
? "errors.error{$status}"
|
||||
: 'errors.error500';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the given exception into an Illuminate response.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Response $response
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
protected function toIlluminateResponse($response, Throwable $e)
|
||||
{
|
||||
if ($response instanceof SymfonyRedirectResponse) {
|
||||
$response = new RedirectResponse(
|
||||
$response->getTargetUrl(),
|
||||
$response->getStatusCode(),
|
||||
$response->headers->all()
|
||||
);
|
||||
} else {
|
||||
$response = new Response(
|
||||
$response->getContent(),
|
||||
$response->getStatusCode(),
|
||||
$response->headers->all()
|
||||
);
|
||||
}
|
||||
|
||||
return $response->withException($e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a JSON response for the given exception.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
protected function prepareJsonResponse($request, Throwable $e)
|
||||
{
|
||||
return new JsonResponse(
|
||||
$this->convertExceptionToArray($e),
|
||||
$this->isHttpException($e) ? $e->getStatusCode() : 500,
|
||||
$this->isHttpException($e) ? $e->getHeaders() : [],
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given exception to an array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function convertExceptionToArray(Throwable $e)
|
||||
{
|
||||
return config('debug') ? [
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => get_class($e),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => collect($e->getTrace())->map(function ($trace) {
|
||||
return Arr::except($trace, ['args']);
|
||||
})->all(),
|
||||
] : [
|
||||
// Leantime exceptions expose a curated, client-safe message; fall back to the raw
|
||||
// HttpException message (or a generic string) for everything else. Mirrors the
|
||||
// JSON-RPC surface (JsonRpcErrorResponse::fromException), which also uses getClientMessage().
|
||||
'message' => $e instanceof LeantimeExceptionInterface
|
||||
? $e->getClientMessage()
|
||||
: ($this->isHttpException($e) ? $e->getMessage() : 'Server Error'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an exception to the console.
|
||||
*
|
||||
* @param \Symfony\Component\Console\Output\OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
public function renderForConsole($output, Throwable $e)
|
||||
{
|
||||
(new ConsoleApplication)->renderThrowable($e, $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the given exception is an HTTP exception.
|
||||
*
|
||||
* @phpstan-assert-if-true \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function isHttpException(Throwable $e)
|
||||
{
|
||||
return $e instanceof HttpExceptionInterface;
|
||||
}
|
||||
}
|
||||
361
app/Core/Exceptions/HandleExceptions.php
Normal file
361
app/Core/Exceptions/HandleExceptions.php
Normal file
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use ErrorException;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
use Illuminate\Log\LogManager;
|
||||
use Illuminate\Support\Env;
|
||||
use Monolog\Handler\NullHandler;
|
||||
use PHPUnit\Runner\ErrorHandler;
|
||||
use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
use Symfony\Component\ErrorHandler\Error\FatalError;
|
||||
use Throwable;
|
||||
|
||||
class HandleExceptions
|
||||
{
|
||||
/**
|
||||
* Reserved memory so that errors can be displayed properly on memory exhaustion.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
public static $reservedMemory;
|
||||
|
||||
/**
|
||||
* The application instance.
|
||||
*
|
||||
* @var \Leantime\Core\Application|null
|
||||
*/
|
||||
protected static $app;
|
||||
|
||||
/**
|
||||
* Bootstrap the given application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function bootstrap(\Leantime\Core\Application $app)
|
||||
{
|
||||
static::$reservedMemory = str_repeat('x', 32768);
|
||||
|
||||
static::$app = $app;
|
||||
|
||||
error_reporting(-1);
|
||||
|
||||
set_error_handler($this->forwardsTo('handleError'));
|
||||
|
||||
set_exception_handler($this->forwardsTo('handleException'));
|
||||
|
||||
register_shutdown_function($this->forwardsTo('handleShutdown'));
|
||||
|
||||
if (! $app->environment('testing')) {
|
||||
ini_set('display_errors', 'Off');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report PHP deprecations, or convert PHP errors to ErrorException instances.
|
||||
*
|
||||
* @param int $level
|
||||
* @param string $message
|
||||
* @param string $file
|
||||
* @param int $line
|
||||
* @return void
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function handleError($level, $message, $file = '', $line = 0)
|
||||
{
|
||||
if ($this->isDeprecation($level)) {
|
||||
$this->handleDeprecationError($message, $file, $line, $level);
|
||||
} elseif (error_reporting() & $level) {
|
||||
throw new ErrorException($message, 0, $level, $file, $line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a deprecation to the "deprecations" logger.
|
||||
*
|
||||
* @param string $message
|
||||
* @param string $file
|
||||
* @param int $line
|
||||
* @param int $level
|
||||
* @return void
|
||||
*/
|
||||
public function handleDeprecationError($message, $file, $line, $level = E_DEPRECATED)
|
||||
{
|
||||
if ($this->shouldIgnoreDeprecationErrors()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$logger = static::$app->make(LogManager::class);
|
||||
} catch (Exception) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ensureDeprecationLoggerIsConfigured();
|
||||
|
||||
$options = static::$app['config']->get('logging.deprecations') ?? [];
|
||||
|
||||
with($logger->channel('deprecations'), function ($log) use ($message, $file, $line, $level, $options) {
|
||||
if ($options['trace'] ?? false) {
|
||||
$log->warning((string) new ErrorException($message, 0, $level, $file, $line));
|
||||
} else {
|
||||
$log->warning(sprintf('%s in %s on line %s',
|
||||
$message, $file, $line
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if deprecation errors should be ignored.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function shouldIgnoreDeprecationErrors()
|
||||
{
|
||||
return ! class_exists(LogManager::class)
|
||||
|| ! static::$app->hasBeenBootstrapped()
|
||||
|| (static::$app->runningUnitTests() && ! Env::get('LOG_DEPRECATIONS_WHILE_TESTING'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the "deprecations" logger is configured.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function ensureDeprecationLoggerIsConfigured()
|
||||
{
|
||||
with(static::$app['config'], function ($config) {
|
||||
if ($config->get('logging.channels.deprecations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ensureNullLogDriverIsConfigured();
|
||||
|
||||
if (is_array($options = $config->get('logging.deprecations'))) {
|
||||
$driver = $options['channel'] ?? 'null';
|
||||
} else {
|
||||
$driver = $options ?? 'null';
|
||||
}
|
||||
|
||||
$config->set('logging.channels.deprecations', $config->get("logging.channels.{$driver}"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the "null" log driver is configured.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function ensureNullLogDriverIsConfigured()
|
||||
{
|
||||
with(static::$app['config'], function ($config) {
|
||||
if ($config->get('logging.channels.null')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config->set('logging.channels.null', [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an uncaught exception from the application.
|
||||
*
|
||||
* Note: Most exceptions can be handled via the try / catch block in
|
||||
* the HTTP and Console kernels. But, fatal error exceptions must
|
||||
* be handled differently since they are not normal exceptions.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handleException(Throwable $e)
|
||||
{
|
||||
static::$reservedMemory = null;
|
||||
|
||||
try {
|
||||
$this->getExceptionHandler()->report($e);
|
||||
} catch (Exception) {
|
||||
$exceptionHandlerFailed = true;
|
||||
}
|
||||
|
||||
if (static::$app->runningInConsole()) {
|
||||
$this->renderForConsole($e);
|
||||
|
||||
if ($exceptionHandlerFailed ?? false) {
|
||||
exit(1);
|
||||
}
|
||||
} else {
|
||||
$this->renderHttpResponse($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an exception to the console.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function renderForConsole(Throwable $e)
|
||||
{
|
||||
$this->getExceptionHandler()->renderForConsole(new ConsoleOutput, $e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an exception as an HTTP response and send it.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function renderHttpResponse(Throwable $e)
|
||||
{
|
||||
$this->getExceptionHandler()->render(request(), $e)->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the PHP shutdown event.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handleShutdown()
|
||||
{
|
||||
static::$reservedMemory = null;
|
||||
|
||||
if (! is_null($error = error_get_last()) && $this->isFatal($error['type'])) {
|
||||
$this->handleException($this->fatalErrorFromPhpError($error, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new fatal error instance from an error array.
|
||||
*
|
||||
* @param int|null $traceOffset
|
||||
* @return \Symfony\Component\ErrorHandler\Error\FatalError
|
||||
*/
|
||||
protected function fatalErrorFromPhpError(array $error, $traceOffset = null)
|
||||
{
|
||||
return new FatalError($error['message'], 0, $error, $traceOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a method call to the given method if an application instance exists.
|
||||
*
|
||||
* @return callable
|
||||
*/
|
||||
protected function forwardsTo($method)
|
||||
{
|
||||
return fn (...$arguments) => static::$app
|
||||
? $this->{$method}(...$arguments)
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the error level is a deprecation.
|
||||
*
|
||||
* @param int $level
|
||||
* @return bool
|
||||
*/
|
||||
protected function isDeprecation($level)
|
||||
{
|
||||
return in_array($level, [E_DEPRECATED, E_USER_DEPRECATED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the error type is fatal.
|
||||
*
|
||||
* @param int $type
|
||||
* @return bool
|
||||
*/
|
||||
protected function isFatal($type)
|
||||
{
|
||||
return in_array($type, [E_COMPILE_ERROR, E_CORE_ERROR, E_ERROR, E_PARSE]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an instance of the exception handler.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Debug\ExceptionHandler
|
||||
*/
|
||||
protected function getExceptionHandler()
|
||||
{
|
||||
return static::$app->make(ExceptionHandler::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the local application instance from memory.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @deprecated This method will be removed in a future Laravel version.
|
||||
*/
|
||||
public static function forgetApp()
|
||||
{
|
||||
static::$app = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the bootstrapper's global state.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function flushState()
|
||||
{
|
||||
if (is_null(static::$app)) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::flushHandlersState();
|
||||
|
||||
static::$app = null;
|
||||
|
||||
static::$reservedMemory = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the bootstrapper's global handlers state.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function flushHandlersState()
|
||||
{
|
||||
while (true) {
|
||||
$previousHandler = set_exception_handler(static fn () => null);
|
||||
|
||||
restore_exception_handler();
|
||||
|
||||
if ($previousHandler === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
restore_exception_handler();
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// @phpstan-ignore-next-line argument.type
|
||||
$previousHandler = set_error_handler(static fn () => null);
|
||||
|
||||
restore_error_handler();
|
||||
|
||||
if ($previousHandler === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
restore_error_handler();
|
||||
}
|
||||
|
||||
if (class_exists(ErrorHandler::class)) {
|
||||
$instance = ErrorHandler::instance();
|
||||
|
||||
// The closure is rebound to $instance (PHPUnit's ErrorHandler, which has $enabled) via
|
||||
// ->call(); PHPStan analyses it in this class's scope (no $enabled) and wrongly collapses
|
||||
// it to always-false. The check is live at runtime.
|
||||
// @phpstan-ignore-next-line
|
||||
if ((fn () => $this->enabled ?? false)->call($instance)) {
|
||||
$instance->disable();
|
||||
$instance->enable();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/Core/Exceptions/InvalidArgumentException.php
Normal file
27
app/Core/Exceptions/InvalidArgumentException.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Invalid argument supplied to an operation (HTTP 422 / JSON-RPC -32602 invalid params).
|
||||
*
|
||||
* Now a {@see LeantimeException}; for user-facing input validation prefer
|
||||
* {@see ValidationException}, which additionally carries a per-field error map.
|
||||
*/
|
||||
class InvalidArgumentException extends LeantimeException
|
||||
{
|
||||
protected int $rpcCode = -32602;
|
||||
|
||||
/**
|
||||
* @param string $message The exception message.
|
||||
* @param int $code HTTP status, also exposed via getStatusCode() and getCode().
|
||||
* @param Throwable|null $previous Previous throwable for chaining.
|
||||
*/
|
||||
public function __construct(string $message = '', int $code = 422, ?Throwable $previous = null)
|
||||
{
|
||||
$this->statusCode = $code;
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
77
app/Core/Exceptions/LeantimeException.php
Normal file
77
app/Core/Exceptions/LeantimeException.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Leantime\Core\Exceptions\Contracts\LeantimeExceptionInterface;
|
||||
|
||||
/**
|
||||
* Base class for Leantime's first-class domain exceptions.
|
||||
*
|
||||
* Extends plain \Exception (so existing `catch (\Exception)` / `catch (SpecificException)`
|
||||
* sites keep working) and implements LeantimeExceptionInterface, which in turn extends
|
||||
* Symfony's HttpExceptionInterface — that single inheritance is what lets the global
|
||||
* ExceptionHandler honor getStatusCode()/getHeaders() with no handler changes.
|
||||
*
|
||||
* Subclasses set $statusCode and $rpcCode (and may carry $errorData / override
|
||||
* getClientMessage()). See LeantimeExceptionInterface for the design rationale.
|
||||
*/
|
||||
abstract class LeantimeException extends \Exception implements LeantimeExceptionInterface
|
||||
{
|
||||
/**
|
||||
* HTTP status for the web/REST surfaces.
|
||||
*/
|
||||
protected int $statusCode = 500;
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 error code. Defaults to the spec's reserved "Internal error".
|
||||
*/
|
||||
protected int $rpcCode = -32603;
|
||||
|
||||
/**
|
||||
* Optional structured detail surfaced in the JSON-RPC error `data` member.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $errorData = [];
|
||||
|
||||
/**
|
||||
* Response headers to attach (HttpExceptionInterface contract).
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $headers = [];
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
public function getRpcCode(): int
|
||||
{
|
||||
return $this->rpcCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getErrorData(): array
|
||||
{
|
||||
return $this->errorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-safe message. Defaults to getMessage(); override to curate what a client sees.
|
||||
*/
|
||||
public function getClientMessage(): string
|
||||
{
|
||||
return $this->getMessage();
|
||||
}
|
||||
}
|
||||
28
app/Core/Exceptions/MissingParameterException.php
Normal file
28
app/Core/Exceptions/MissingParameterException.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* A required parameter was missing (HTTP 422 / JSON-RPC -32602 invalid params).
|
||||
*
|
||||
* A degenerate validation failure. Now a {@see LeantimeException}, so a service throwing
|
||||
* this during a JSON-RPC call surfaces as a proper -32602 "Invalid params" instead of a
|
||||
* generic server error.
|
||||
*/
|
||||
class MissingParameterException extends LeantimeException
|
||||
{
|
||||
protected int $rpcCode = -32602;
|
||||
|
||||
/**
|
||||
* @param string $message The exception message.
|
||||
* @param int $code HTTP status, also exposed via getStatusCode() and getCode().
|
||||
* @param Throwable|null $previous Previous throwable for chaining.
|
||||
*/
|
||||
public function __construct(string $message = '', int $code = 422, ?Throwable $previous = null)
|
||||
{
|
||||
$this->statusCode = $code;
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
26
app/Core/Exceptions/NotFoundException.php
Normal file
26
app/Core/Exceptions/NotFoundException.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Thrown when a specifically requested resource does not exist.
|
||||
*
|
||||
* Renders as HTTP 404 on the web/REST surfaces and JSON-RPC error -32002 on /api/jsonrpc.
|
||||
*
|
||||
* Use this for a missing *single* requested entity (e.g. getTicket(99) where 99 is gone) —
|
||||
* NOT for an empty list/query result, which should still return `[]`. Throwing here keeps
|
||||
* "not found" distinct from "no permission" (today both collapse to `false`).
|
||||
*/
|
||||
class NotFoundException extends LeantimeException
|
||||
{
|
||||
protected int $statusCode = 404;
|
||||
|
||||
protected int $rpcCode = -32002;
|
||||
|
||||
public function __construct(string $message = 'The requested resource could not be found.', ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
}
|
||||
79
app/Core/Exceptions/ReportableHandler.php
Normal file
79
app/Core/Exceptions/ReportableHandler.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Illuminate\Support\Traits\ReflectsClosures;
|
||||
use Throwable;
|
||||
|
||||
class ReportableHandler
|
||||
{
|
||||
use ReflectsClosures;
|
||||
|
||||
/**
|
||||
* The underlying callback.
|
||||
*
|
||||
* @var callable
|
||||
*/
|
||||
protected $callback;
|
||||
|
||||
/**
|
||||
* Indicates if reporting should stop after invoking this handler.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $shouldStop = false;
|
||||
|
||||
/**
|
||||
* Create a new reportable handler instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(callable $callback)
|
||||
{
|
||||
$this->callback = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the handler.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function __invoke(Throwable $e)
|
||||
{
|
||||
$result = call_user_func($this->callback, $e);
|
||||
|
||||
if ($result === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $this->shouldStop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the callback handles the given exception.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function handles(Throwable $e)
|
||||
{
|
||||
foreach ($this->firstClosureParameterTypes($this->callback) as $type) {
|
||||
if (is_a($e, $type)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that report handling should stop after invoking this callback.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function stop()
|
||||
{
|
||||
$this->shouldStop = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
74
app/Core/Exceptions/ValidationException.php
Normal file
74
app/Core/Exceptions/ValidationException.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Illuminate\Translation\ArrayLoader;
|
||||
use Illuminate\Translation\Translator;
|
||||
use Illuminate\Validation\Factory as ValidationFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Thrown when user-supplied input fails validation.
|
||||
*
|
||||
* Renders as HTTP 422 on the web/REST surfaces and JSON-RPC error -32602 ("Invalid params")
|
||||
* on /api/jsonrpc, with the per-field errors serialized into the JSON-RPC error `data` member.
|
||||
*
|
||||
* Per the agreed approach, this is a Leantime-owned type so the whole application sees ONE
|
||||
* validation exception — but services may still author rules with Laravel's Validator and let
|
||||
* the static validate() bridge run them and rethrow as this type. Field errors are carried as
|
||||
* a ['field' => ['message', ...]] map (the same shape Laravel's MessageBag::toArray() produces).
|
||||
*/
|
||||
class ValidationException extends LeantimeException
|
||||
{
|
||||
protected int $statusCode = 422;
|
||||
|
||||
protected int $rpcCode = -32602;
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, string>> $errors Field => messages map.
|
||||
*/
|
||||
public function __construct(array $errors = [], string $message = 'The given data was invalid.', ?Throwable $previous = null)
|
||||
{
|
||||
$this->errorData = $errors;
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build directly from a field => messages map.
|
||||
*
|
||||
* @param array<string, array<int, string>> $errors
|
||||
*/
|
||||
public static function withMessages(array $errors): self
|
||||
{
|
||||
return new self($errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Laravel validation rules and either return the validated data or throw this
|
||||
* exception. The bridge that lets services use Laravel's Validator while the rest of
|
||||
* the app only ever handles Leantime's ValidationException.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $rules
|
||||
* @param array<string, string> $messages
|
||||
* @return array<string, mixed> The validated subset of $data.
|
||||
*
|
||||
* @throws static
|
||||
*/
|
||||
public static function validate(array $data, array $rules, array $messages = []): array
|
||||
{
|
||||
// Leantime rebinds the container's "translator" to its own Language class, which is
|
||||
// NOT an Illuminate Translator — so the Validator facade cannot be constructed here.
|
||||
// Build a self-contained factory instead. (Wiring Leantime's i18n into validation
|
||||
// messages is future work; until then a message falls back to the rule key unless an
|
||||
// explicit override is passed in $messages.)
|
||||
$validator = (new ValidationFactory(new Translator(new ArrayLoader, 'en')))
|
||||
->make($data, $rules, $messages);
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw new self($validator->errors()->toArray());
|
||||
}
|
||||
|
||||
return $validator->validated();
|
||||
}
|
||||
}
|
||||
90
app/Core/Exceptions/WhoopsHandler.php
Normal file
90
app/Core/Exceptions/WhoopsHandler.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Exceptions;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Arr;
|
||||
use Whoops\Handler\PrettyPageHandler;
|
||||
|
||||
class WhoopsHandler
|
||||
{
|
||||
/**
|
||||
* Create a new Whoops handler for debug mode.
|
||||
*
|
||||
* @return \Whoops\Handler\PrettyPageHandler
|
||||
*/
|
||||
public function forDebug()
|
||||
{
|
||||
return tap(new PrettyPageHandler, function ($handler) {
|
||||
$handler->handleUnconditionally(true);
|
||||
|
||||
$this->registerApplicationPaths($handler)
|
||||
->registerBlacklist($handler)
|
||||
->registerEditor($handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the application paths with the handler.
|
||||
*
|
||||
* @param \Whoops\Handler\PrettyPageHandler $handler
|
||||
* @return $this
|
||||
*/
|
||||
protected function registerApplicationPaths($handler)
|
||||
{
|
||||
$handler->setApplicationPaths(
|
||||
array_flip($this->directoriesExceptVendor())
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application paths except for the "vendor" directory.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function directoriesExceptVendor()
|
||||
{
|
||||
return Arr::except(
|
||||
array_flip((new Filesystem)->directories(APP_ROOT)),
|
||||
[APP_ROOT.'/vendor']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the blacklist with the handler.
|
||||
*
|
||||
* @param \Whoops\Handler\PrettyPageHandler $handler
|
||||
* @return $this
|
||||
*/
|
||||
protected function registerBlacklist($handler)
|
||||
{
|
||||
foreach (config('debug_blacklist', config('debug_hide', [])) as $key => $secrets) {
|
||||
foreach ($secrets as $secret) {
|
||||
$handler->blacklist($key, $secret);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the editor with the handler.
|
||||
*
|
||||
* @param \Whoops\Handler\PrettyPageHandler $handler
|
||||
* @return $this
|
||||
*/
|
||||
protected function registerEditor($handler)
|
||||
{
|
||||
|
||||
$editor = config('editor');
|
||||
if (config('editor', false)) {
|
||||
$handler->setEditor(config('editor'));
|
||||
} else {
|
||||
$handler->setEditor('phpstorm');
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
50
app/Core/Files/Contracts/FileManagerInterface.php
Normal file
50
app/Core/Files/Contracts/FileManagerInterface.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Files\Contracts;
|
||||
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* FileManagerInterface - Interface for file management operations
|
||||
*/
|
||||
interface FileManagerInterface
|
||||
{
|
||||
/**
|
||||
* Upload a file
|
||||
*
|
||||
* @param UploadedFile $file The file to upload
|
||||
* @param string $disk Where to push the file to
|
||||
* @return array|false Array with file info or false on failure
|
||||
*/
|
||||
public function upload(UploadedFile $file, string $disk = 'default'): array|false;
|
||||
|
||||
/**
|
||||
* Get a file
|
||||
*
|
||||
* @param string $fileName The file name (with extension)
|
||||
* @param string $realName The original file name (for Content-Disposition)
|
||||
* @param string $disk The disk the file lives on
|
||||
* @return Response|false Response object or false on failure
|
||||
*/
|
||||
public function getFile(string $fileName, string $realName, string $disk = 'default'): Response|false;
|
||||
|
||||
/**
|
||||
* Get a public/temporary URL for a file
|
||||
*
|
||||
* @param string $fileName The file name (with extension)
|
||||
* @param string $disk The disk the file lives on
|
||||
* @param int $expires Minutes until a temporary URL expires (0 = permanent/public URL)
|
||||
* @return string|false The URL or false on failure
|
||||
*/
|
||||
public function getFileUrl(string $fileName, string $disk = 'default', int $expires = 0): string|false;
|
||||
|
||||
/**
|
||||
* Delete a file
|
||||
*
|
||||
* @param string $fileName The file name (with extension)
|
||||
* @param string $disk The disk the file lives on
|
||||
* @return bool True on success, false on failure
|
||||
*/
|
||||
public function deleteFile(string $fileName, string $disk = 'default'): bool;
|
||||
}
|
||||
71
app/Core/Files/Exceptions/FileValidationException.php
Normal file
71
app/Core/Files/Exceptions/FileValidationException.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Files\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when file validation fails
|
||||
*/
|
||||
class FileValidationException extends Exception
|
||||
{
|
||||
// Error codes
|
||||
public const INVALID_FILE = 1001;
|
||||
|
||||
public const INVALID_MIME_TYPE = 1002;
|
||||
|
||||
public const FILE_TOO_LARGE = 1003;
|
||||
|
||||
public const MALICIOUS_CONTENT = 1004;
|
||||
|
||||
public const DIMENSIONS_TOO_LARGE = 1005;
|
||||
|
||||
public const VALIDATION_ERROR = 1099;
|
||||
|
||||
/**
|
||||
* @var string The user-friendly error message
|
||||
*/
|
||||
protected $userMessage;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $message The error message
|
||||
* @param int $code The error code
|
||||
* @param Exception|null $previous The previous exception
|
||||
* @param string|null $userMessage A user-friendly error message
|
||||
*/
|
||||
public function __construct(string $message, int $code = 0, ?Exception $previous = null, ?string $userMessage = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
$this->userMessage = $userMessage ?? $this->getUserMessageFromCode($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message based on the error code
|
||||
*
|
||||
* @param int $code The error code
|
||||
* @return string The user-friendly message
|
||||
*/
|
||||
protected function getUserMessageFromCode(int $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
self::INVALID_FILE => 'The file is invalid or corrupted. Please try uploading a different file.',
|
||||
self::INVALID_MIME_TYPE => 'This file type is not allowed. Please upload a supported file type.',
|
||||
self::FILE_TOO_LARGE => 'The file is too large. Please upload a smaller file.',
|
||||
self::MALICIOUS_CONTENT => 'The file contains potentially malicious content and cannot be uploaded.',
|
||||
self::DIMENSIONS_TOO_LARGE => 'The image dimensions are too large. Please resize the image and try again.',
|
||||
default => 'An error occurred while validating the file. Please try again with a different file.'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user-friendly error message
|
||||
*
|
||||
* @return string The user-friendly error message
|
||||
*/
|
||||
public function getUserMessage(): string
|
||||
{
|
||||
return $this->userMessage;
|
||||
}
|
||||
}
|
||||
458
app/Core/Files/FileManager.php
Normal file
458
app/Core/Files/FileManager.php
Normal file
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Files;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Files\Contracts\FileManagerInterface;
|
||||
use Leantime\Core\Files\Exceptions\FileValidationException;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* FileManager - Service for handling file operations using Laravel's filesystem
|
||||
*/
|
||||
class FileManager implements FileManagerInterface
|
||||
{
|
||||
private FilesystemManager $filesystemManager;
|
||||
|
||||
private Environment $config;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(
|
||||
FilesystemManager $filesystemManager,
|
||||
Environment $config,
|
||||
) {
|
||||
$this->filesystemManager = $filesystemManager;
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename to prevent path traversal and other security issues
|
||||
* Source: https://stackoverflow.com/questions/2021624/string-sanitizer-for-filename
|
||||
*
|
||||
* @param string $filename The filename to sanitize
|
||||
* @return string Sanitized filename
|
||||
*/
|
||||
private function sanitizeFilename(string $filename): string
|
||||
{
|
||||
// Remove any directory paths
|
||||
$filename = basename($filename);
|
||||
|
||||
// sanitize filename
|
||||
$filename = preg_replace(
|
||||
'~
|
||||
[<>:"/\\\|?*]| # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
|
||||
[\x00-\x1F]| # control characters http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx
|
||||
[\x7F\xA0\xAD]| # non-printing characters DEL, NO-BREAK SPACE, SOFT HYPHEN
|
||||
[#\[\]@!$&\'()+,;=]| # URI reserved https://www.rfc-editor.org/rfc/rfc3986#section-2.2
|
||||
[{}^\~`] # URL unsafe characters https://www.ietf.org/rfc/rfc1738.txt
|
||||
~x',
|
||||
'-', $filename);
|
||||
// avoids ".", ".." or ".hiddenFiles"
|
||||
$filename = ltrim($filename, '.-');
|
||||
|
||||
// maximize filename length to 255 bytes http://serverfault.com/a/9548/44086
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
|
||||
return mb_strcut(pathinfo($filename, PATHINFO_FILENAME), 0, 255 - ($ext ? strlen($ext) + 1 : 0), mb_detect_encoding($filename)).($ext ? '.'.$ext : '');
|
||||
}
|
||||
|
||||
public function filter_filename($filename, $beautify = true) {}
|
||||
|
||||
/**
|
||||
* File extensions that are never allowed to be uploaded.
|
||||
* These can be executed server-side or used to override server configuration.
|
||||
*/
|
||||
private const DENIED_EXTENSIONS = [
|
||||
'php',
|
||||
'phtml',
|
||||
'php3',
|
||||
'php4',
|
||||
'php5',
|
||||
'phar',
|
||||
'htaccess',
|
||||
'shtml',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates a file before upload
|
||||
*
|
||||
* @param UploadedFile $file The file to validate
|
||||
* @param string $module The module name
|
||||
*
|
||||
* @throws FileValidationException If the file is invalid
|
||||
*/
|
||||
private function validateFile(UploadedFile $file, string $module = 'default'): void
|
||||
{
|
||||
// Check if file is valid
|
||||
if (! $file->isValid() || $file->getError()) {
|
||||
Log::error('Invalid file upload attempt: '.$file->getErrorMessage());
|
||||
throw new FileValidationException('Invalid file upload attempt: '.$file->getErrorMessage(), FileValidationException::INVALID_FILE);
|
||||
}
|
||||
|
||||
// Check file size (10MB max by default, can be configured)
|
||||
$maxSize = self::getMaximumFileUploadSize();
|
||||
if ($file->getSize() > $maxSize) {
|
||||
throw new FileValidationException('File size exceeds the maximum allowed size of '.format($maxSize)->formatBytes(), FileValidationException::FILE_TOO_LARGE);
|
||||
}
|
||||
|
||||
// Reject dangerous file extensions that could be executed server-side
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
if (in_array($extension, self::DENIED_EXTENSIONS, true)) {
|
||||
Log::warning('Blocked upload of dangerous file type', [
|
||||
'extension' => $extension,
|
||||
'originalName' => $file->getClientOriginalName(),
|
||||
'userId' => session('userdata.id'),
|
||||
]);
|
||||
throw new FileValidationException(
|
||||
'File type .'.$extension.' is not allowed',
|
||||
FileValidationException::INVALID_MIME_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
// Also check for double extensions like file.php.jpg that could bypass
|
||||
// some server configurations
|
||||
$originalName = strtolower($file->getClientOriginalName());
|
||||
foreach (self::DENIED_EXTENSIONS as $deniedExt) {
|
||||
if (str_contains($originalName, '.'.$deniedExt.'.')) {
|
||||
Log::warning('Blocked upload with dangerous double extension', [
|
||||
'originalName' => $file->getClientOriginalName(),
|
||||
'userId' => session('userdata.id'),
|
||||
]);
|
||||
throw new FileValidationException(
|
||||
'File contains a disallowed extension in its name',
|
||||
FileValidationException::INVALID_MIME_TYPE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// SVG files can contain embedded scripts and event handlers.
|
||||
// Reject them outright to prevent stored XSS attacks.
|
||||
if ($extension === 'svg' || $extension === 'svgz') {
|
||||
Log::info('Blocked SVG upload for security', [
|
||||
'originalName' => $file->getClientOriginalName(),
|
||||
'userId' => session('userdata.id'),
|
||||
]);
|
||||
throw new FileValidationException(
|
||||
'SVG file uploads are not allowed for security reasons',
|
||||
FileValidationException::INVALID_MIME_TYPE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file
|
||||
*
|
||||
* @param UploadedFile $file The file to upload
|
||||
* @param string $disk The disk to use for storage
|
||||
* @return array|false Array with file info or false on failure
|
||||
*/
|
||||
public function upload(UploadedFile $file, $disk = 'default'): array|false
|
||||
{
|
||||
try {
|
||||
// Validate file before proceeding
|
||||
$this->validateFile($file, $disk);
|
||||
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$realName = $this->sanitizeFilename($file->getClientOriginalName());
|
||||
$fileName = $realName;
|
||||
|
||||
if ($disk === 'default') {
|
||||
$disk = $this->filesystemManager->getDefaultDriver();
|
||||
}
|
||||
|
||||
$visibility = null;
|
||||
if ($disk === 'public' && $this->config->useS3) {
|
||||
$disk = 's3';
|
||||
$visibility = 'public';
|
||||
}
|
||||
|
||||
$storage = $this->filesystemManager->disk($disk);
|
||||
|
||||
$newName = pathinfo($fileName, PATHINFO_FILENAME);
|
||||
if (config('filesystems.disks.'.$disk.'.renameFiles')) {
|
||||
$newName = md5(session('userdata.id').time());
|
||||
$fileName = $newName.'.'.$extension;
|
||||
}
|
||||
|
||||
// Store the file
|
||||
$stream = fopen($file->getRealPath(), 'r');
|
||||
$result = $storage->put($fileName, $stream, $visibility);
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
return [
|
||||
'encName' => $newName,
|
||||
'realName' => $realName,
|
||||
'extension' => $extension,
|
||||
'fileName' => $fileName,
|
||||
'newPath' => $newName.'.'.$extension,
|
||||
'path' => $fileName,
|
||||
'moduleId' => '',
|
||||
'module' => '',
|
||||
'userId' => session('userdata.id'),
|
||||
'fileId' => '',
|
||||
'uploadTime' => time(),
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Enhanced error logging
|
||||
Log::error('File upload failed: '.$e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'file' => $file->getClientOriginalName(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file
|
||||
*
|
||||
* @param string $fileName The file name (with extension)
|
||||
* @param string $realName The original file name (for Content-Disposition)
|
||||
* @param string $disk The disk to use for storage
|
||||
* @return Response|false Response object or false on failure
|
||||
*/
|
||||
public function getFile(string $fileName, string $realName, string $disk = 'default'): Response|false
|
||||
{
|
||||
try {
|
||||
// Determine the disk to use
|
||||
if ($disk === 'default') {
|
||||
$disk = $this->filesystemManager->getDefaultDriver();
|
||||
}
|
||||
|
||||
// Public disk but using s3 means we are getting files from S3 but they have public visibility
|
||||
if ($disk === 'public' && $this->config->useS3) {
|
||||
$disk = 's3';
|
||||
}
|
||||
|
||||
$storage = $this->filesystemManager->disk($disk);
|
||||
|
||||
// Check if file exists
|
||||
if (! $storage->exists($fileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get file mime type
|
||||
$mimeType = $storage->mimeType($fileName) ?: 'application/octet-stream';
|
||||
|
||||
// Read file contents directly instead of using download() which relies
|
||||
// on fpassthru() — a function disabled on many shared hosting environments.
|
||||
$content = $storage->get($fileName);
|
||||
|
||||
$response = new Response($content);
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
$response->headers->set('Content-Length', (string) $storage->size($fileName));
|
||||
$response->headers->set('Content-Disposition', 'inline; filename="'.$realName.'"');
|
||||
|
||||
// Sandbox all user-uploaded files to prevent script execution
|
||||
$response->headers->set('Content-Security-Policy', 'sandbox');
|
||||
|
||||
// Force download for content types that can execute scripts (HTML, SVG, XML)
|
||||
// to prevent inline rendering of potentially malicious content
|
||||
$dangerousMimeTypes = [
|
||||
'text/html',
|
||||
'application/xhtml+xml',
|
||||
'image/svg+xml',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
];
|
||||
|
||||
if (in_array(strtolower($mimeType), $dangerousMimeTypes, true)) {
|
||||
$response->headers->set('Content-Disposition', 'attachment; filename="'.$realName.'"');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
if (! $this->config->debug) {
|
||||
$response->headers->set('Pragma', 'public');
|
||||
$response->headers->set('Cache-Control', 'max-age=86400');
|
||||
$response->headers->set('Last-Modified', gmdate('D, d M Y H:i:s', $storage->lastModified($fileName)).' GMT');
|
||||
}
|
||||
|
||||
return $response;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error getting file: '.$e->getMessage(), [
|
||||
'fileName' => $fileName,
|
||||
'disk' => $disk,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file URL
|
||||
*
|
||||
* @param string $fileName The file name (with extension)
|
||||
* @param string $disk The disk to use for storage
|
||||
* @param int $expires Number of minutes before URL expires (for S3)
|
||||
* @return string|false File URL or false on failure
|
||||
*/
|
||||
public function getFileUrl(string $fileName, string $disk = 'default', $expires = 0): string|false
|
||||
{
|
||||
try {
|
||||
// Determine the disk to use
|
||||
if ($disk === 'default') {
|
||||
$disk = $this->filesystemManager->getDefaultDriver();
|
||||
}
|
||||
|
||||
if ($disk === 'public' && $this->config->useS3) {
|
||||
$disk = 's3';
|
||||
}
|
||||
|
||||
$storage = $this->filesystemManager->disk($disk);
|
||||
|
||||
// Check if file exists
|
||||
if (! $storage->exists($fileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get file mime type
|
||||
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
|
||||
$mimeType = $this->filesystemManager->mimeType($fileName);
|
||||
|
||||
if ($disk === 's3') {
|
||||
try {
|
||||
// Generate a signed URL with proper expiration
|
||||
// Use configured expiration time if not specified
|
||||
if ($expires <= 0) {
|
||||
$expires = (int) $this->config->get('filesystems.url_expiration', 60);
|
||||
}
|
||||
|
||||
// Add some randomness to prevent cache stampede
|
||||
$jitter = random_int(0, min(5, $expires / 10));
|
||||
$expiration = dtHelper()->now()->addMinutes($expires + $jitter);
|
||||
|
||||
return $storage->temporaryUrl($fileName, $expiration);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to generate S3 temporary URL: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Use caching for non-S3 files
|
||||
$cacheEnabled = $this->config->get('filesystems.cache.enabled', true);
|
||||
$cacheDuration = $this->config->get('filesystems.cache.duration', 60);
|
||||
$cacheKey = "file_url_{$disk}_{$fileName}";
|
||||
|
||||
if ($cacheEnabled && $disk !== 's3') {
|
||||
return Cache::remember($cacheKey, $cacheDuration * 60, static function () use ($storage, $fileName) {
|
||||
return $storage->url($fileName);
|
||||
});
|
||||
}
|
||||
|
||||
return $storage->url($fileName);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error getting file URL: '.$e->getMessage(), [
|
||||
'fileName' => $fileName,
|
||||
'disk' => $disk,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file
|
||||
*
|
||||
* @param string $fileName The file name (with extension)
|
||||
* @param string $disk The disk to use for storage
|
||||
* @return bool True on success, false on failure
|
||||
*/
|
||||
public function deleteFile(string $fileName, string $disk = 'default'): bool
|
||||
{
|
||||
if (empty($fileName)) {
|
||||
Log::warning('Attempted to delete a file with empty filename');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine the disk to use
|
||||
if ($disk === 'default') {
|
||||
$disk = $this->filesystemManager->getDefaultDriver();
|
||||
}
|
||||
|
||||
$storage = $this->filesystemManager->disk($disk);
|
||||
|
||||
// Check if file exists before attempting deletion
|
||||
if (! $storage->exists($fileName)) {
|
||||
Log::info("File not found for deletion: {$fileName} on disk {$disk}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
return $storage->delete($fileName);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting file: '.$e->getMessage(), [
|
||||
'fileName' => $fileName,
|
||||
'disk' => $disk,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function returns the maximum files size that can be uploaded in PHP
|
||||
*
|
||||
* @return int The filesize allowed by php.ini config in bytes
|
||||
*/
|
||||
public static function getMaximumFileUploadSize(): int
|
||||
{
|
||||
return min(self::convertPHPSizeToBytes(ini_get('post_max_size')), self::convertPHPSizeToBytes(ini_get('upload_max_filesize')));
|
||||
}
|
||||
|
||||
/**
|
||||
* This function transforms the php.ini notation for numbers (like '2M') to an integer (2*1024*1024 in this case)
|
||||
*
|
||||
* @return int The value in bytes
|
||||
*/
|
||||
private static function convertPHPSizeToBytes(string $sSize): int
|
||||
{
|
||||
$sSuffix = strtoupper(substr($sSize, -1));
|
||||
if (! in_array($sSuffix, ['P', 'T', 'G', 'M', 'K'])) {
|
||||
return (int) $sSize;
|
||||
}
|
||||
$iValue = (int) substr($sSize, 0, -1);
|
||||
switch ($sSuffix) {
|
||||
case 'P':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
// no break
|
||||
case 'T':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
// no break
|
||||
case 'G':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
// no break
|
||||
case 'M':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
// no break
|
||||
case 'K':
|
||||
$iValue *= 1024;
|
||||
break;
|
||||
}
|
||||
|
||||
return (int) $iValue;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user