OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View 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));
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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");
}
}
}

View 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
View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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
View 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');
}
}

View 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,
]);
}
}

View 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']
);
});
}
}

View 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;
}

View 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);
}
}

View 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;
}
}

View 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, '.');
}
}

View 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;
}
}
}

View 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;
}
}

View 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();
}
}

View 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();
}
}

View 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;
});
}
}

View 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));
});
}
}

View 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;
}

View 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,
) {}
}

View 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;
}
}

View 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
View 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);
}
}
}

View 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() {}
}

View 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
));
}
}

View 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,
];
}
}

View 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 {}
}

View 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';
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Leantime\Core\Configuration\Attributes;
use Attribute;
#[Attribute]
class LaravelConfig
{
public function __construct(
public string $config,
) {
//
}
}

View 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;
}

View 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);
}
}

View 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');
}
}

View 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';
}

File diff suppressed because it is too large Load Diff

View 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');
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Leantime\Core\Console;
use Leantime\Core\Http\IncomingRequest;
class CliRequest extends IncomingRequest
{
//
public function handle() {}
}

View 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));
}
}

View 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);
});
}
}
}
}

View 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,
];
}

View 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 [];
}
}

View 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
));
}
}

View 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);
}
}

View 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
));
}
}

View 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 [];
}
}

View 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());
}
}
}

View 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);
});
}
}

View 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;
}
}

View 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
View 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
View 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
View 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;
}
}

View 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
View 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();
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace Leantime\Core\Domains;
interface DomainModel {}

View 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);
}

View 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 {}

View 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');
});
}
}

View 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));
}
}

View 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);
}
}

View 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;
}

View 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;
}

View 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'] ?? '';
}
}

View 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');
}
}

View 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';
}

View 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() {}
}

View 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;
}

View 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));
}
}

View 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';
}

View 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 : '');
}
}

View 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;
}
}

View 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);
}
}

View 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;
}

View 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);
}
}

View 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;
}
}

View 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();
}
}
}
}

View 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);
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View 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;
}
}

View 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;
}

View 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;
}
}

View 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