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

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

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

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

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

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

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

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

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

View File

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

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

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

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