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