OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
82
app/Core/Http/ApiRequest.php
Normal file
82
app/Core/Http/ApiRequest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http;
|
||||
|
||||
/**
|
||||
* Class ApiRequest
|
||||
*
|
||||
* Represents an API request.
|
||||
*/
|
||||
class ApiRequest extends IncomingRequest
|
||||
{
|
||||
/**
|
||||
* Retrieves the Authorization header from the request.
|
||||
* The method checks multiple keys in the request headers for Authorization,
|
||||
* including 'Authorization', 'HTTP_AUTHORIZATION', and 'REDIRECT_HTTP_AUTHORIZATION'.
|
||||
* If the header is found, it is trimmed and returned as a string.
|
||||
* If the header is not found in the request headers, the method falls back to using the
|
||||
* getallheaders() function to retrieve all the request headers and checks for the
|
||||
* 'Authorization' header. If found, it is trimmed and returned as a string.
|
||||
* If no Authorization header is found, an empty string is returned.
|
||||
*
|
||||
* @return string The Authorization header value, or an empty string if not found.
|
||||
*/
|
||||
public function getAuthorizationHeader(): string
|
||||
{
|
||||
foreach (
|
||||
[
|
||||
'Authorization',
|
||||
// Nginx or fast CGI
|
||||
'HTTP_AUTHORIZATION',
|
||||
// Nginx or fast CGI
|
||||
'REDIRECT_HTTP_AUTHORIZATION',
|
||||
] as $key
|
||||
) {
|
||||
$header = trim($this->headers->get($key, ''));
|
||||
|
||||
if (! empty($header)) {
|
||||
return $header;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback
|
||||
$allheaders = getallheaders();
|
||||
foreach ($allheaders as $name => $value) {
|
||||
if (strtolower($name) == 'authorization') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the API key from the request headers.
|
||||
*
|
||||
* @return string The API key, or an empty string if not found.
|
||||
*/
|
||||
public function getAPIKey(): string
|
||||
{
|
||||
return $this->headers->get('x-api-key') ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bearer token from the authorization header.
|
||||
*
|
||||
* @return string|null The bearer token if found, null otherwise.
|
||||
*/
|
||||
public function getBearerToken(): ?string
|
||||
{
|
||||
// Check for Sanctum token first
|
||||
$header = $this->getAuthorizationHeader();
|
||||
if (str_starts_with($header, 'Bearer ')) {
|
||||
return substr($header, 7);
|
||||
}
|
||||
|
||||
if ($token = $this->bearerToken()) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
307
app/Core/Http/Client/ApiClient.php
Normal file
307
app/Core/Http/Client/ApiClient.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http\Client;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Subscriber\Oauth\Oauth1;
|
||||
use kamermans\OAuth2\GrantType\AuthorizationCode;
|
||||
use kamermans\OAuth2\GrantType\ClientCredentials;
|
||||
use kamermans\OAuth2\GrantType\GrantTypeInterface;
|
||||
use kamermans\OAuth2\GrantType\PasswordCredentials;
|
||||
use kamermans\OAuth2\GrantType\RefreshToken;
|
||||
use kamermans\OAuth2\OAuth2Middleware;
|
||||
|
||||
/**
|
||||
* ApiSession - Creates a Guzzle Client with a connection
|
||||
*/
|
||||
class ApiClient
|
||||
{
|
||||
/**
|
||||
* Checks passed credentials to see if they are properly provided
|
||||
*
|
||||
* @see https://github.com/kamermans/guzzle-oauth2-subscriber#middleware-guzzle-6
|
||||
*
|
||||
* @param array $optionalCreds (optional)
|
||||
*/
|
||||
private static function checkCreds(
|
||||
array $requiredCreds,
|
||||
array $creds,
|
||||
array $optionalCreds = []
|
||||
): bool {
|
||||
if (! empty($optionalCreds)) {
|
||||
foreach ($optionalCreds as $optionalCred) {
|
||||
if (isset($creds[$optionalCred])) {
|
||||
unset($creds[$optionalCred]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($creds) || ! empty(array_diff($requiredCreds, array_keys($creds)))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Guzzle Client with an oAuth2 connection
|
||||
*
|
||||
* @see https://github.com/kamermans/guzzle-oauth2-subscriber#client-credentials-example
|
||||
*/
|
||||
public static function oAuth2(
|
||||
string $baseUri,
|
||||
HandlerStack $stack,
|
||||
array $requestDefaults = []
|
||||
): Client {
|
||||
return new Client(
|
||||
array_merge_recursive(
|
||||
$requestDefaults,
|
||||
[
|
||||
'base_uri' => $baseUri,
|
||||
'handler' => $stack,
|
||||
'auth' => 'oauth',
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for oAuth2 Client
|
||||
*
|
||||
* @see https://github.com/kamermans/guzzle-oauth2-subscriber
|
||||
*
|
||||
* @param array $creds Just pass an empty array if you supply $customGrantType.
|
||||
* @param bool $usesRefresh (optional)
|
||||
* @param GrantTypeInterface|null $customGrantType (optional)
|
||||
*/
|
||||
public static function oAuth2Grants(
|
||||
string $baseUri,
|
||||
array $creds,
|
||||
bool $usesRefresh = false,
|
||||
?GrantTypeInterface $customGrantType = null
|
||||
): HandlerStack {
|
||||
$middleware_params = [];
|
||||
|
||||
if ($customGrantType == null) {
|
||||
$requiredCreds = [
|
||||
'client_id',
|
||||
'client_secret',
|
||||
];
|
||||
$optionalCreds = [
|
||||
'scope',
|
||||
'state',
|
||||
'redirect_uri',
|
||||
'code',
|
||||
];
|
||||
|
||||
if (! self::checkCreds($requiredCreds, $creds, $optionalCreds)) {
|
||||
throw new \Error(
|
||||
'oAuth2 credentials were incorrectly provided'
|
||||
);
|
||||
}
|
||||
|
||||
$client = new Client(['base_uri' => $baseUri]);
|
||||
|
||||
if (in_array('code', $creds)) {
|
||||
$middleware_params[] = new AuthorizationCode($client, $creds);
|
||||
} elseif (in_array('username', $creds) && in_array('password', $creds)) {
|
||||
$middleware_params[] = new PasswordCredentials($client, $creds);
|
||||
} else {
|
||||
$middleware_params[] = new ClientCredentials($client, $creds);
|
||||
}
|
||||
|
||||
if ($usesRefresh) {
|
||||
$middleware_params[] = new RefreshToken($client, $creds);
|
||||
}
|
||||
|
||||
} else {
|
||||
$middleware_params[] = $customGrantType;
|
||||
}
|
||||
|
||||
$stack = HandlerStack::create();
|
||||
$oauth = new OAuth2Middleware(...$middleware_params);
|
||||
$stack->push($oauth);
|
||||
|
||||
return $stack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Guzzle Client with an oAuth1 connection
|
||||
*
|
||||
* @see https://github.com/guzzle/oauth-subscriber#using-the-subscriber
|
||||
*
|
||||
* @param array $requestDefaults (optional)
|
||||
*/
|
||||
public static function oAuth1(
|
||||
string $baseUri,
|
||||
array $creds,
|
||||
array $requestDefaults = []
|
||||
): Client {
|
||||
$requiredCreds = [
|
||||
'consumer_key',
|
||||
'consumer_secret',
|
||||
'token',
|
||||
'token_secret',
|
||||
];
|
||||
$optionalCreds = [
|
||||
'private_key_file',
|
||||
'private_key_passphrase',
|
||||
'signature_method',
|
||||
];
|
||||
|
||||
if (! self::checkCreds($requiredCreds, $creds, $optionalCreds)) {
|
||||
throw new \Error(
|
||||
'oAuth1 credentials were incorrectly provided'
|
||||
);
|
||||
}
|
||||
|
||||
$stack = HandlerStack::create();
|
||||
$middleware = new Oauth1($creds);
|
||||
$stack->push($middleware);
|
||||
|
||||
return new Client(
|
||||
array_merge_recursive(
|
||||
$requestDefaults,
|
||||
[
|
||||
'base_uri' => $baseUri,
|
||||
'auth' => 'oauth',
|
||||
'handler' => $stack,
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Guzzle Client with a basic authentication connection
|
||||
*
|
||||
* @see https://docs.guzzlephp.org/en/latest/request-options.html#auth
|
||||
*
|
||||
* @param array $requestDefaults (optional)
|
||||
*/
|
||||
public static function basicAuth(
|
||||
string $baseUri,
|
||||
array $creds,
|
||||
array $requestDefaults = []
|
||||
): Client {
|
||||
$requiredCreds = [
|
||||
'username',
|
||||
'password',
|
||||
];
|
||||
|
||||
if (! self::checkCreds($requiredCreds, $creds)) {
|
||||
throw new \Error(
|
||||
"basic auth credentials must match exactly: ['username' => ..., 'password' => ...]"
|
||||
);
|
||||
}
|
||||
|
||||
return new Client(
|
||||
array_merge_recursive(
|
||||
$requestDefaults,
|
||||
[
|
||||
'base_uri' => $baseUri,
|
||||
'auth' => $creds,
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Guzzle Client with a digest connection
|
||||
*
|
||||
* @see https://docs.guzzlephp.org/en/latest/request-options.html#auth
|
||||
*
|
||||
* @param array $requestDefaults (optional)
|
||||
*/
|
||||
public static function digest(
|
||||
string $baseUri,
|
||||
array $creds,
|
||||
array $requestDefaults = []
|
||||
): Client {
|
||||
$requiredCreds = [
|
||||
'username',
|
||||
'password',
|
||||
'digest',
|
||||
];
|
||||
|
||||
if (! self::checkCreds($requiredCreds, $creds)) {
|
||||
throw new \Error(
|
||||
"basic auth credentials must match exactly: ['username' => ..., 'password' => ..., 'digest' => ...]"
|
||||
);
|
||||
}
|
||||
|
||||
return new Client(
|
||||
array_merge_recursive([
|
||||
$requestDefaults,
|
||||
[
|
||||
'base_uri' => $baseUri,
|
||||
'auth' => $creds,
|
||||
],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Guzzle Client with a ntlm connection
|
||||
*
|
||||
* @see https://docs.guzzlephp.org/en/latest/request-options.html#auth
|
||||
*
|
||||
* @param array $requestDefaults (optional)
|
||||
*/
|
||||
public static function ntlm(
|
||||
string $baseUri,
|
||||
array $creds,
|
||||
array $requestDefaults = []
|
||||
): Client {
|
||||
$requiredCreds = [
|
||||
'username',
|
||||
'password',
|
||||
'ntlm',
|
||||
];
|
||||
|
||||
if (! self::checkCreds($requiredCreds, $creds)) {
|
||||
throw new \Error(
|
||||
"basic auth credentials must match exactly: ['username' => ..., 'password' => ..., 'ntlm' => ...]"
|
||||
);
|
||||
}
|
||||
|
||||
return new Client(
|
||||
array_merge_recursive(
|
||||
$requestDefaults,
|
||||
[
|
||||
'base_uri' => $baseUri,
|
||||
'auth' => $creds,
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Guzzle Client with a token/apikey connection
|
||||
*
|
||||
* @param array $requestDefaults (optional)
|
||||
*/
|
||||
public static function bearerToken(
|
||||
string $baseUri,
|
||||
array $creds,
|
||||
array $requestDefaults = []
|
||||
): Client {
|
||||
$requiredCreds = ['token'];
|
||||
|
||||
if (! self::checkCreds($requiredCreds, $creds)) {
|
||||
throw new \Error(
|
||||
"bearer token credentials must match exactly: ['token' => ...]"
|
||||
);
|
||||
}
|
||||
|
||||
return new Client(
|
||||
array_merge_recursive(
|
||||
$requestDefaults,
|
||||
[
|
||||
'base_uri' => $baseUri,
|
||||
'headers' => ['Authorization' => 'Bearer '.$creds['token'].''],
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
88
app/Core/Http/HtmxRequest.php
Normal file
88
app/Core/Http/HtmxRequest.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Core\Http;
|
||||
|
||||
class HtmxRequest extends IncomingRequest
|
||||
{
|
||||
/**
|
||||
* Get HTMX request information
|
||||
*
|
||||
* @return array<string, string|bool|null>
|
||||
*/
|
||||
public function getHtmxRequestVars(): array
|
||||
{
|
||||
return [
|
||||
'boosted' => $this->isBoosted(),
|
||||
'referrer' => $this->getReferrer(),
|
||||
'isHistoryRestoreRequest' => $this->isHistoryRestoreRequest(),
|
||||
'prompt' => $this->getPromptResponse(),
|
||||
'target' => $this->getTarget(),
|
||||
'triggerName' => $this->getTriggerName(),
|
||||
'triggerId' => $this->getTriggerId(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that the request is via an element using hx-boost
|
||||
*/
|
||||
public function isBoosted(): bool
|
||||
{
|
||||
return filter_var(
|
||||
$this->headers->get('Hx-Boost', 'false'),
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Current URL of the browser when the htmx request was made.
|
||||
*/
|
||||
public function getReferrer(): string
|
||||
{
|
||||
return $this->headers->get('Hx-Current-URL', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the request is for history restoration after a miss in the local history cache
|
||||
*/
|
||||
public function isHistoryRestoreRequest(): bool
|
||||
{
|
||||
return filter_var(
|
||||
$this->headers->get('Hx-History-Restore-Request', 'false'),
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user response to an hx-prompt.
|
||||
*/
|
||||
public function getPromptResponse(): string
|
||||
{
|
||||
return $this->headers->get('Hx-Prompt', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the target element if it exists.
|
||||
*/
|
||||
public function getTarget(): string
|
||||
{
|
||||
return $this->headers->get('Hx-Target', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the triggered element if it exists.
|
||||
*/
|
||||
public function getTriggerName(): string
|
||||
{
|
||||
return $this->headers->get('Hx-Trigger-Name');
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the triggered element if it exists.
|
||||
*/
|
||||
public function getTriggerId(): string
|
||||
{
|
||||
return $this->headers->get('Hx-Trigger', '');
|
||||
}
|
||||
}
|
||||
218
app/Core/Http/HttpKernel.php
Normal file
218
app/Core/Http/HttpKernel.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http;
|
||||
|
||||
use Illuminate\Foundation\Http\Events\RequestHandled;
|
||||
use Illuminate\Foundation\Http\Kernel;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Middleware\AuthenticateSession;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class HttpKernel extends Kernel
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected $bootstrappers = [
|
||||
\Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
|
||||
\Leantime\Core\Bootstrap\LoadConfig::class,
|
||||
\Illuminate\Foundation\Bootstrap\HandleExceptions::class,
|
||||
\Illuminate\Foundation\Bootstrap\RegisterFacades::class,
|
||||
\Illuminate\Foundation\Bootstrap\RegisterProviders::class,
|
||||
\Illuminate\Foundation\Bootstrap\BootProviders::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's global HTTP middleware stack.
|
||||
*
|
||||
* These middleware are run during every request to your application.
|
||||
*
|
||||
* @var array<int, class-string|string>
|
||||
*/
|
||||
protected $middleware = [
|
||||
|
||||
// \Illuminate\Session\Middleware\StartSession::class,
|
||||
// \Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
// \Illuminate\Auth\Middleware\Authenticate::class,
|
||||
// \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
// \Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
// \Illuminate\Auth\Middleware\Authorize::class,
|
||||
// \Illuminate\Http\Middleware\TrustHosts::class,
|
||||
|
||||
// \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
|
||||
// \Illuminate\Cookie\Middleware\EncryptCookies::class,
|
||||
// \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
|
||||
\Leantime\Core\Middleware\TrustProxies::class,
|
||||
|
||||
\Leantime\Core\Middleware\StartSession::class,
|
||||
\Leantime\Core\Middleware\Installed::class,
|
||||
\Leantime\Core\Middleware\Updated::class,
|
||||
|
||||
// All enabled plugins will be available from here on out
|
||||
\Leantime\Core\Middleware\LoadPlugins::class,
|
||||
|
||||
\Leantime\Core\Middleware\InitialHeaders::class,
|
||||
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||
|
||||
// CSRF verification is NOT yet global — ~82 legacy .tpl.php forms lack @csrf tokens.
|
||||
// Enable globally only after all forms are tokenized. Until then, apply per-route.
|
||||
// \Leantime\Core\Middleware\VerifyCsrfToken::class,
|
||||
|
||||
\Leantime\Core\Middleware\AuthCheck::class,
|
||||
\Leantime\Core\Middleware\AuthenticateSession::class,
|
||||
|
||||
\Leantime\Core\Middleware\RequestRateLimiter::class,
|
||||
\Illuminate\Http\Middleware\HandleCors::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||
\Leantime\Core\Middleware\TrimStrings::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||
\Leantime\Core\Middleware\SetCacheHeaders::class,
|
||||
\Leantime\Core\Middleware\Localization::class,
|
||||
|
||||
\Leantime\Domain\Projects\Middleware\CurrentProject::class,
|
||||
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's route middleware groups.
|
||||
*
|
||||
* @var array<string, array<int, class-string|string>>
|
||||
*/
|
||||
protected $middlewareGroups = [
|
||||
'web' => [
|
||||
],
|
||||
'api' => [
|
||||
],
|
||||
'hx' => [
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's middleware aliases.
|
||||
*
|
||||
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
|
||||
*
|
||||
* @var array<string, class-string|string>
|
||||
*/
|
||||
protected $middlewareAliases = [
|
||||
'auth' => \Leantime\Core\Middleware\AuthCheck::class,
|
||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||
'auth.session' => AuthenticateSession::class,
|
||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||
// 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
||||
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
||||
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
|
||||
// 'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||
];
|
||||
|
||||
public function handle($request)
|
||||
{
|
||||
$this->requestStartedAt = Carbon::now();
|
||||
|
||||
try {
|
||||
$response = $this->sendRequestThroughRouter($request);
|
||||
} catch (\Throwable $e) {
|
||||
$this->reportException($e);
|
||||
|
||||
$response = $this->renderException($request, $e);
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line argument.type
|
||||
$this->app['events']->dispatch(new RequestHandled($request, $response));
|
||||
|
||||
$response = self::dispatch_filter('beforeSendResponse', $response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function sendRequestThroughRouter($request)
|
||||
{
|
||||
$this->app->instance('request', $request);
|
||||
|
||||
Facade::clearResolvedInstance('request');
|
||||
|
||||
$this->bootstrap();
|
||||
|
||||
// Events are discovered and available as part of bootstrapping the providers.
|
||||
// Can savely assume events are available here.
|
||||
self::dispatch_event('request_started', ['request' => $request]);
|
||||
|
||||
// if ($request instanceof ApiRequest) {
|
||||
//
|
||||
// array_splice($this->middleware, 6, 0, $this->middlewareGroups['api']);
|
||||
//
|
||||
// } else {
|
||||
// array_splice($this->middleware, 6, 0, $this->middlewareGroups['web']);
|
||||
// }
|
||||
|
||||
// This filter only works for system plugins
|
||||
// Regular plugins are not available until after install verification
|
||||
$this->middleware = self::dispatch_filter('middleware', $this->middleware, ['request' => $request]);
|
||||
|
||||
// Main Pipeline
|
||||
$response = (new \Illuminate\Routing\Pipeline($this->app))
|
||||
->send($request)
|
||||
->through($this->middleware)
|
||||
->then(fn ($request) =>
|
||||
// Then run through plugin pipeline
|
||||
(new \Illuminate\Routing\Pipeline($this->app))
|
||||
->send($request)
|
||||
->through(self::dispatch_filter(
|
||||
hook: 'plugins_middleware',
|
||||
payload: [],
|
||||
function: 'handle',
|
||||
))
|
||||
->then(fn () => $this->findAndDispatchToRouter($request))
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function terminate($request, $response)
|
||||
{
|
||||
|
||||
self::dispatchEvent('request_terminated', [$request, $response]);
|
||||
|
||||
parent::terminate($request, $response);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch request to router with Laravel routing precedence
|
||||
*
|
||||
* Tries Laravel routing first, falls back to Frontcontroller if no route found
|
||||
*
|
||||
* Frontcontroller is now deprecated and will be removed in future versions once we have route files for everythihng
|
||||
*/
|
||||
protected function findAndDispatchToRouter($request)
|
||||
{
|
||||
|
||||
$this->app->instance('request', $request);
|
||||
|
||||
try {
|
||||
// Try Laravel routing first
|
||||
$this->router->getRoutes()->match($request);
|
||||
|
||||
// Use Laravel's router to handle the request
|
||||
return $this->router->dispatch($request);
|
||||
} catch (NotFoundHttpException $e) {
|
||||
// No Laravel route found, fall back to Frontcontroller
|
||||
} catch (\Exception $e) {
|
||||
// Log other exceptions but continue to Frontcontroller
|
||||
if (config('app.debug')) {
|
||||
Log::error($e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Leantime's Frontcontroller routing
|
||||
return Frontcontroller::dispatch_request($request);
|
||||
}
|
||||
}
|
||||
349
app/Core/Http/IncomingRequest.php
Normal file
349
app/Core/Http/IncomingRequest.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Http\RequestTypes\RequestTypeDetector;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Incoming Request information
|
||||
*/
|
||||
class IncomingRequest extends \Illuminate\Http\Request
|
||||
{
|
||||
/**
|
||||
* The decoded JSON content for the request.
|
||||
*
|
||||
* @var \Symfony\Component\HttpFoundation\InputBag|null
|
||||
*/
|
||||
protected $json;
|
||||
|
||||
protected $pageUrl;
|
||||
|
||||
protected $currentRoute;
|
||||
|
||||
private $basePathCalculated = false;
|
||||
|
||||
private $pathInfoCalculated = false;
|
||||
|
||||
public const HEADER_FORWARDED = parent::HEADER_FORWARDED; // When using RFC 7239
|
||||
|
||||
public const HEADER_X_FORWARDED_FOR = parent::HEADER_X_FORWARDED_FOR;
|
||||
|
||||
public const HEADER_X_FORWARDED_HOST = parent::HEADER_X_FORWARDED_HOST;
|
||||
|
||||
public const HEADER_X_FORWARDED_PROTO = parent::HEADER_X_FORWARDED_PROTO;
|
||||
|
||||
public const HEADER_X_FORWARDED_PORT = parent::HEADER_X_FORWARDED_PORT;
|
||||
|
||||
public const HEADER_X_FORWARDED_PREFIX = parent::HEADER_X_FORWARDED_PREFIX;
|
||||
|
||||
public const HEADER_X_FORWARDED_AWS_ELB = parent::HEADER_X_FORWARDED_AWS_ELB; // AWS ELB doesn't send X-Forwarded-Host
|
||||
|
||||
public const HEADER_X_FORWARDED_TRAEFIK = parent::HEADER_X_FORWARDED_TRAEFIK; // All "X-Forwarded-*"
|
||||
|
||||
// List of valid api endpoint urls
|
||||
public array $apiEndpoints = [
|
||||
'/api/jsonrpc',
|
||||
'/mcp',
|
||||
'/api',
|
||||
];
|
||||
|
||||
public static function createFromGlobals(): static
|
||||
{
|
||||
return parent::createFromBase(parent::createFromGlobals());
|
||||
}
|
||||
|
||||
public static function capture(): IncomingRequest
|
||||
{
|
||||
parent::enableHttpMethodParameterOverride();
|
||||
|
||||
$request = self::createFromGlobals();
|
||||
$requestClass = RequestTypeDetector::detect($request);
|
||||
|
||||
return $requestClass::createFromBase($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the full URL including request uri and protocol
|
||||
*/
|
||||
public function getFullUrl(): string
|
||||
{
|
||||
return $this->getSchemeAndHttpHost().$this->getBasePath().$this->getPathInfo();
|
||||
}
|
||||
|
||||
public function getBasePath(): string
|
||||
{
|
||||
|
||||
// Early in the stack we may not have BASE_URL yet.
|
||||
// Let's have symfony deal with it
|
||||
if (! defined('BASE_URL')) {
|
||||
return $this->prepareBasePath();
|
||||
}
|
||||
|
||||
// Will always only return the domain portion
|
||||
if (! $this->basePathCalculated) {
|
||||
$schemeHost = $this->getSchemeAndHttpHost();
|
||||
$baseUrl = rtrim(BASE_URL, '/');
|
||||
|
||||
// Extract potential subfolder from BASE_URL
|
||||
if ($baseUrl !== $schemeHost) {
|
||||
$this->basePath = substr($baseUrl, strlen($schemeHost));
|
||||
} else {
|
||||
$this->basePath = $this->prepareBasePath();
|
||||
}
|
||||
|
||||
$this->basePathCalculated = true;
|
||||
}
|
||||
|
||||
return $this->basePath;
|
||||
}
|
||||
|
||||
public function getPathInfo(): string
|
||||
{
|
||||
if (! $this->pathInfoCalculated) {
|
||||
$pathInfo = $this->preparePathInfo();
|
||||
$basePath = $this->getBasePath();
|
||||
|
||||
// Only strip basePath if it exists at the start of pathInfo
|
||||
if ($basePath && strpos($pathInfo, $basePath) === 0) {
|
||||
$this->pathInfo = substr($pathInfo, strlen($basePath));
|
||||
} else {
|
||||
$this->pathInfo = $pathInfo;
|
||||
}
|
||||
|
||||
$this->pathInfoCalculated = true;
|
||||
}
|
||||
|
||||
return $this->pathInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request URI (path behind domain name)
|
||||
* Will adjust for subfolder installations
|
||||
*/
|
||||
public function getRequestUri(): string
|
||||
{
|
||||
|
||||
if ($this->requestUri === null) {
|
||||
$requestUri = parent::getRequestUri();
|
||||
$basePath = $this->getBasePath();
|
||||
|
||||
// If we have a basePath (subfolder installation)
|
||||
// and it exists at the start of the requestUri,
|
||||
// strip it out to get the correct relative path
|
||||
if ($basePath && str_starts_with($requestUri, $basePath)) {
|
||||
$requestUri = substr($requestUri, strlen($basePath));
|
||||
}
|
||||
|
||||
// Ensure requestUri starts with a forward slash
|
||||
if (! str_starts_with($requestUri, '/')) {
|
||||
$requestUri = '/'.$requestUri;
|
||||
}
|
||||
|
||||
$this->requestUri = $requestUri;
|
||||
}
|
||||
|
||||
return $this->requestUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request params
|
||||
*/
|
||||
public function getRequestParams(?string $method = null): array
|
||||
{
|
||||
$method ??= $this->method();
|
||||
$method = strtoupper($method);
|
||||
$patch_vars = [];
|
||||
|
||||
if ($method === 'PATCH') {
|
||||
parse_str($this->getContent(), $patch_vars);
|
||||
}
|
||||
|
||||
$params = $this->query->all();
|
||||
|
||||
// Merge query vars with post or patch vars
|
||||
return match ($method) {
|
||||
'PATCH' => array_merge($params, $patch_vars),
|
||||
'POST' => array_merge($this->request->all(), $params),
|
||||
default => $params
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full URL of the current request.
|
||||
* Wrapper for Laravel
|
||||
*
|
||||
* @return string The full URL of the current request.
|
||||
*
|
||||
* @Override
|
||||
*/
|
||||
public function fullUrl(): string
|
||||
{
|
||||
return $this->getFullUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the current request is an API or Cron request.
|
||||
*
|
||||
* @return bool Returns true if the request is an API or Cron request, false otherwise.
|
||||
*/
|
||||
public function isApiOrCronRequest(): bool
|
||||
{
|
||||
$requestUri = $this->getRequestUri();
|
||||
|
||||
return str_starts_with(strtolower($requestUri), '/api/jsonrpc') || str_starts_with($requestUri, '/cron');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the current request targets the MCP server endpoint.
|
||||
*
|
||||
* @return bool Returns true if the request is an MCP request, false otherwise.
|
||||
*/
|
||||
public function isMcpRequest(): bool
|
||||
{
|
||||
$requestUri = strtolower($this->getRequestUri());
|
||||
|
||||
return $requestUri === '/mcp'
|
||||
|| str_starts_with($requestUri, '/mcp/')
|
||||
|| str_starts_with($requestUri, '/mcp?');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the current request is an Htmx request.
|
||||
*
|
||||
* @return bool Returns true if the request is an Htmx request, false otherwise.
|
||||
*/
|
||||
public function isHtmxRequest(): bool
|
||||
{
|
||||
return ! empty($this->headers->get('Hx-Request'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the current request is a boosted htmx request.
|
||||
*
|
||||
* @return bool Returns true if the request is a boosted htmx request, false otherwise.
|
||||
*/
|
||||
public function isBoostedHtmxRequest(): bool
|
||||
{
|
||||
return $this->isHtmxRequest() &&
|
||||
$this->headers->get('Hx-Boost') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the current request is an unboosted HTMX request.
|
||||
*
|
||||
* @return bool Returns true if the request is an unboosted HTMX request, false otherwise.
|
||||
*/
|
||||
public function isUnboostedHtmxRequest(): bool
|
||||
{
|
||||
return $this->isHtmxRequest() &&
|
||||
empty($this->headers->get('Hx-Boost'));
|
||||
}
|
||||
|
||||
public function getCurrentRoute()
|
||||
{
|
||||
if ($this->currentRoute === null) {
|
||||
$path = $this->getPathInfo();
|
||||
$path = trim($path, '/');
|
||||
|
||||
if (empty($path)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$route = str_replace('/', '.', $path);
|
||||
|
||||
$this->currentRoute = $route;
|
||||
}
|
||||
|
||||
return $this->currentRoute;
|
||||
}
|
||||
|
||||
public function segments(): array
|
||||
{
|
||||
$segments = explode('/', $this->decodedPath());
|
||||
|
||||
return array_values(array_filter($segments, static function ($value) {
|
||||
return $value !== '';
|
||||
}));
|
||||
}
|
||||
|
||||
public function decodedPath(): string
|
||||
{
|
||||
return rawurldecode($this->path());
|
||||
}
|
||||
|
||||
public function path(): string
|
||||
{
|
||||
$pattern = trim($this->getPathInfo(), '/');
|
||||
|
||||
return $pattern === '' ? '/' : $pattern;
|
||||
}
|
||||
|
||||
public function setCurrentRoute($route): void
|
||||
{
|
||||
$this->currentRoute = $route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the module name from the given complete name or the current route.
|
||||
*
|
||||
* @param string|null $completeName The complete name from which to extract the module name. If not provided, the current route will be used.
|
||||
* @return string The module name.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
public function getModuleName(?string $completeName = null): string
|
||||
{
|
||||
$completeName ??= $this->getCurrentRoute();
|
||||
$actionParts = explode('.', empty($completeName) ? $this->currentRoute : $completeName);
|
||||
|
||||
if (is_array($actionParts)) {
|
||||
return $actionParts[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getActionName - split string to get actionName
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
public function getActionName(?string $completeName = null): string
|
||||
{
|
||||
$completeName ??= $this->getCurrentRoute();
|
||||
$actionParts = explode('.', empty($completeName) ? $this->currentRoute : $completeName);
|
||||
$actionName = '';
|
||||
|
||||
// If no action name was given, call index controller
|
||||
if (is_array($actionParts) && count($actionParts) === 1) {
|
||||
$actionName = 'index';
|
||||
}
|
||||
|
||||
if (is_array($actionParts) && count($actionParts) === 2) {
|
||||
$actionName = $actionParts[1];
|
||||
}
|
||||
|
||||
return $actionName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current request is an API request.
|
||||
*
|
||||
* @return bool Returns true if the current request is an API request, false otherwise.
|
||||
*/
|
||||
public function isApiRequest(): bool
|
||||
{
|
||||
$requestUri = strtolower($this->getRequestUri());
|
||||
|
||||
// Check the endpoint
|
||||
foreach ($this->apiEndpoints as $apiEndpoint) {
|
||||
if (str_starts_with($requestUri, $apiEndpoint)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
29
app/Core/Http/RequestTypes/ApiRequestType.php
Normal file
29
app/Core/Http/RequestTypes/ApiRequestType.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http\RequestTypes;
|
||||
|
||||
use Leantime\Core\Http\ApiRequest;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
|
||||
class ApiRequestType implements RequestTypeInterface
|
||||
{
|
||||
public function matches(IncomingRequest $request): bool
|
||||
{
|
||||
$requestUri = strtolower($request->getRequestUri());
|
||||
|
||||
return
|
||||
$request->headers->has('x-api-key')
|
||||
|| $request->bearerToken()
|
||||
|| $request->isApiRequest();
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 300; // Higher priority than HTMX
|
||||
}
|
||||
|
||||
public function getRequestClass(): string
|
||||
{
|
||||
return ApiRequest::class;
|
||||
}
|
||||
}
|
||||
24
app/Core/Http/RequestTypes/HtmxRequestType.php
Normal file
24
app/Core/Http/RequestTypes/HtmxRequestType.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http\RequestTypes;
|
||||
|
||||
use Leantime\Core\Http\HtmxRequest;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
|
||||
class HtmxRequestType implements RequestTypeInterface
|
||||
{
|
||||
public function matches(IncomingRequest $request): bool
|
||||
{
|
||||
return $request->headers->has('HX-Request');
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 200;
|
||||
}
|
||||
|
||||
public function getRequestClass(): string
|
||||
{
|
||||
return HtmxRequest::class;
|
||||
}
|
||||
}
|
||||
38
app/Core/Http/RequestTypes/RequestTypeDetector.php
Normal file
38
app/Core/Http/RequestTypes/RequestTypeDetector.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http\RequestTypes;
|
||||
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
|
||||
class RequestTypeDetector
|
||||
{
|
||||
protected static array $requestTypes = [
|
||||
ApiRequestType::class,
|
||||
HtmxRequestType::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Register a new request type detector
|
||||
*/
|
||||
public static function register(string $typeClass): void
|
||||
{
|
||||
if (! in_array($typeClass, self::$requestTypes)) {
|
||||
self::$requestTypes[] = $typeClass;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the request type from the incoming request
|
||||
*/
|
||||
public static function detect(IncomingRequest $request): string
|
||||
{
|
||||
foreach (self::$requestTypes as $typeClass) {
|
||||
$type = new $typeClass;
|
||||
if ($type->matches($request)) {
|
||||
return $type->getRequestClass();
|
||||
}
|
||||
}
|
||||
|
||||
return IncomingRequest::class;
|
||||
}
|
||||
}
|
||||
24
app/Core/Http/RequestTypes/RequestTypeInterface.php
Normal file
24
app/Core/Http/RequestTypes/RequestTypeInterface.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http\RequestTypes;
|
||||
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
|
||||
interface RequestTypeInterface
|
||||
{
|
||||
/**
|
||||
* Check if the request matches this type
|
||||
*/
|
||||
public function matches(IncomingRequest $request): bool;
|
||||
|
||||
/**
|
||||
* Get the priority of this request type
|
||||
* Higher numbers mean higher priority
|
||||
*/
|
||||
public function getPriority(): int;
|
||||
|
||||
/**
|
||||
* Get the request class to instantiate
|
||||
*/
|
||||
public function getRequestClass(): string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http\Responses\Contracts;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
|
||||
/**
|
||||
* Marker contract for Leantime's first-class HTTP response types.
|
||||
*
|
||||
* Response types live in app/Core/Http/Responses and are returned directly from
|
||||
* controllers; Laravel's router converts them to a Symfony Response via toResponse().
|
||||
* Extending Laravel's Responsable keeps the behaviour fully framework-native while
|
||||
* giving Leantime a single place to discover/centralise every response type it offers
|
||||
* (e.g. ImageResponse today; a JsonRpcResponse, etc. can follow the same pattern).
|
||||
*
|
||||
* @see \Illuminate\Contracts\Support\Responsable
|
||||
*/
|
||||
interface LeantimeResponseInterface extends Responsable {}
|
||||
53
app/Core/Http/Responses/ImageResponse.php
Normal file
53
app/Core/Http/Responses/ImageResponse.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http\Responses;
|
||||
|
||||
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
|
||||
use SVG\SVG;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Response type for avatar/profile images served by the domain image controllers
|
||||
* (Users\Controllers\ProfileImage, Projects\Controllers\ProjectImage).
|
||||
*
|
||||
* The source is either a generated SVG avatar, an already-built file Response (an
|
||||
* uploaded image streamed by the file service), or a filesystem path. Controllers
|
||||
* return `new ImageResponse($source)` and Laravel renders it via toResponse().
|
||||
*/
|
||||
class ImageResponse implements LeantimeResponseInterface
|
||||
{
|
||||
/**
|
||||
* @param SVG|Response|string $image Generated SVG, a pre-built file Response, or a filesystem path
|
||||
*/
|
||||
public function __construct(private SVG|Response|string $image) {}
|
||||
|
||||
/**
|
||||
* Build the cacheable image response.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*/
|
||||
public function toResponse($request): Response
|
||||
{
|
||||
if ($this->image instanceof SVG) {
|
||||
return $this->withCacheHeaders(new Response($this->image->toXMLString()), 'image/svg+xml');
|
||||
}
|
||||
|
||||
if ($this->image instanceof Response) {
|
||||
return $this->image;
|
||||
}
|
||||
|
||||
return $this->withCacheHeaders(new Response(file_get_contents($this->image)), 'application/octet-stream');
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the public, 24h cache headers shared by every image variant.
|
||||
*/
|
||||
private function withCacheHeaders(Response $response, string $contentType): Response
|
||||
{
|
||||
$response->headers->set('Content-type', $contentType);
|
||||
$response->headers->set('Pragma', 'public');
|
||||
$response->headers->set('Cache-Control', 'max-age=86400');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
69
app/Core/Http/Responses/JsonRpcErrorResponse.php
Normal file
69
app/Core/Http/Responses/JsonRpcErrorResponse.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http\Responses;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Leantime\Core\Exceptions\Contracts\LeantimeExceptionInterface;
|
||||
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The JSON-RPC 2.0 error envelope as a first-class response type.
|
||||
*
|
||||
* Centralizes the `{jsonrpc, error: {code, message, data}, id}` wire format and is the single
|
||||
* place that turns a thrown exception into a client-facing JSON-RPC error — without leaking
|
||||
* internal detail. A LeantimeException maps to its own code/message/data; any other throwable
|
||||
* is reported as a generic server error (the caller is responsible for logging it).
|
||||
*
|
||||
* Note: JSON-RPC carries the error in-band, so the HTTP status stays 200; real HTTP status
|
||||
* codes belong on the web/REST surfaces (handled by the global ExceptionHandler), not here.
|
||||
*
|
||||
* @see https://www.jsonrpc.org/specification#error_object
|
||||
*/
|
||||
class JsonRpcErrorResponse implements LeantimeResponseInterface
|
||||
{
|
||||
public function __construct(
|
||||
private int $code,
|
||||
private string $message,
|
||||
private mixed $data = null,
|
||||
private int|string|null $id = 0,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build an error envelope from a thrown exception.
|
||||
*
|
||||
* A LeantimeException is trusted to expose a client-safe code, message and data.
|
||||
* Anything else is collapsed to a generic server error so internal messages/stack
|
||||
* detail never reach the client; log such throwables at the call site.
|
||||
*/
|
||||
public static function fromException(Throwable $e, int|string|null $id = 0): self
|
||||
{
|
||||
if ($e instanceof LeantimeExceptionInterface) {
|
||||
return new self(
|
||||
$e->getRpcCode(),
|
||||
$e->getClientMessage(),
|
||||
$e->getErrorData() ?: null,
|
||||
$id,
|
||||
);
|
||||
}
|
||||
|
||||
return new self(-32000, 'Server error', null, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*/
|
||||
public function toResponse($request): Response
|
||||
{
|
||||
return new JsonResponse([
|
||||
'jsonrpc' => '2.0',
|
||||
'error' => [
|
||||
'code' => $this->code,
|
||||
'message' => $this->message,
|
||||
'data' => $this->data,
|
||||
],
|
||||
'id' => $this->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
app/Core/Http/Responses/JsonRpcResponse.php
Normal file
45
app/Core/Http/Responses/JsonRpcResponse.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Http\Responses;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The JSON-RPC 2.0 success envelope as a first-class response type.
|
||||
*
|
||||
* Centralizes the `{jsonrpc, result, id}` wire format (previously inlined in the Jsonrpc
|
||||
* controller) in the HTTP layer behind LeantimeResponseInterface, alongside ImageResponse.
|
||||
*
|
||||
* @see https://www.jsonrpc.org/specification#response_object
|
||||
*/
|
||||
class JsonRpcResponse implements LeantimeResponseInterface
|
||||
{
|
||||
/**
|
||||
* @param mixed $result The service return value (any JSON value per spec §5).
|
||||
* @param int|string|null $id The request id; null indicates a notification.
|
||||
*/
|
||||
public function __construct(
|
||||
private mixed $result,
|
||||
private int|string|null $id = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*/
|
||||
public function toResponse($request): Response
|
||||
{
|
||||
// A request without an id is a JSON-RPC notification and MUST NOT be responded to.
|
||||
// @see https://www.jsonrpc.org/specification#notification
|
||||
if ($this->id === null) {
|
||||
return new Response('', Response::HTTP_OK);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'jsonrpc' => '2.0',
|
||||
'result' => $this->result,
|
||||
'id' => $this->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user