OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
108
app/Domain/Api/Controllers/ApiKey.php
Normal file
108
app/Domain/Api/Controllers/ApiKey.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Api\Services\Api as ApiService;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Clients\Services\Clients as ClientService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* API-key controller.
|
||||
*/
|
||||
class ApiKey extends Controller
|
||||
{
|
||||
private ApiService $apiService;
|
||||
|
||||
private ClientService $clientService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function init(ApiService $apiService, ClientService $clientService): void
|
||||
{
|
||||
self::dispatch_event('api_key_init', $this);
|
||||
|
||||
$this->apiService = $apiService;
|
||||
$this->clientService = $clientService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the API key edit form.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->tpl->display('errors.error403');
|
||||
}
|
||||
|
||||
$values = $this->apiService->getApiKeyFormValues($id);
|
||||
|
||||
$this->assignTemplateVars($id, $values);
|
||||
|
||||
return $this->tpl->displayPartial('api.apiKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles API key updates.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->tpl->display('errors.error403');
|
||||
}
|
||||
|
||||
$values = $this->apiService->getApiKeyFormValues($id);
|
||||
|
||||
if (isset($_POST['save'])) {
|
||||
if (isset($_POST[session('formTokenName')]) && $_POST[session('formTokenName')] == session('formTokenValue')) {
|
||||
$this->apiService->updateApiKey($id, $_POST, $_POST['projects'] ?? null);
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notifications.key_updated'), 'success', 'apikey_updated');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.form_token_incorrect'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
$this->assignTemplateVars($id, $values);
|
||||
|
||||
return $this->tpl->displayPartial('api.apiKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns common template variables.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function assignTemplateVars(int $id, array $values): void
|
||||
{
|
||||
$this->apiService->generateFormToken();
|
||||
|
||||
$this->tpl->assign('allProjects', $this->apiService->getAllProjects());
|
||||
$this->tpl->assign('roles', Roles::getRoles());
|
||||
$this->tpl->assign('clients', $this->clientService->getAll());
|
||||
$this->tpl->assign('values', $values);
|
||||
$this->tpl->assign('relations', $this->apiService->getProjectRelationIds($id));
|
||||
$this->tpl->assign('status', $this->apiService->getUserStatusOptions());
|
||||
$this->tpl->assign('id', $id);
|
||||
}
|
||||
}
|
||||
79
app/Domain/Api/Controllers/Canvas.php
Normal file
79
app/Domain/Api/Controllers/Canvas.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* canvas class - Generic canvas API controller
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @TODO: Could this class be change to abstract? As it is a generic class that should never be initiated!
|
||||
*/
|
||||
class Canvas extends Controller
|
||||
{
|
||||
/**
|
||||
* Constant that must be redefined
|
||||
*/
|
||||
protected const CANVAS_NAME = '??';
|
||||
|
||||
private BlueprintsService $blueprintsService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(): void
|
||||
{
|
||||
$this->blueprintsService = app()->make(BlueprintsService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle get requests
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
return $this->tpl->displayJson(['status' => 'Not implemented'], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle post requests
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
return $this->tpl->displayJson(['status' => 'Not implemented'], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* patch - handle patch requests with authorization check
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
|
||||
public function patch(array $params): Response
|
||||
{
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->displayJson(['status' => 'failure'], 400);
|
||||
}
|
||||
|
||||
// The service resolves the item's REAL project and authorizes EDIT against it (throwing
|
||||
// 403 for a missing/foreign item or an insufficient role) before patching — replacing
|
||||
// the previous membership-only check with the permission framework. A false return means
|
||||
// no allowlisted columns were present (a client error, not a denial).
|
||||
if ($this->blueprintsService->patchCanvasItem((int) $params['id'], $params, static::CANVAS_NAME.'canvas') === false) {
|
||||
return $this->tpl->displayJson(['status' => 'no valid fields to update'], 400);
|
||||
}
|
||||
|
||||
return $this->tpl->displayJson(['status' => 'ok']);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete - handle delete requests
|
||||
*/
|
||||
public function delete(array $params): Response
|
||||
{
|
||||
return $this->tpl->displayJson(['status' => 'Not implemented'], 501);
|
||||
}
|
||||
}
|
||||
91
app/Domain/Api/Controllers/DelAPIKey.php
Normal file
91
app/Domain/Api/Controllers/DelAPIKey.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Handles API key deletion.
|
||||
*/
|
||||
class DelAPIKey extends Controller
|
||||
{
|
||||
private UserService $userService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(UserService $userService): void
|
||||
{
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the delete API key confirmation.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->tpl->display('errors.error403');
|
||||
}
|
||||
|
||||
$this->tpl->assign('user', $this->userService->getUser($id));
|
||||
$this->generateFormTokens();
|
||||
|
||||
return $this->tpl->display('api.delKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles API key deletion.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->tpl->display('errors.error403');
|
||||
}
|
||||
|
||||
if (isset($_POST['del'])) {
|
||||
if (isset($_POST[session('formTokenName')]) && $_POST[session('formTokenName')] == session('formTokenValue')) {
|
||||
$this->userService->deleteUser($id);
|
||||
$this->tpl->setNotification($this->language->__('notifications.key_deleted'), 'success', 'apikey_deleted');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/setting/editCompanySettings/#apiKeys');
|
||||
}
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.form_token_incorrect'), 'error');
|
||||
}
|
||||
|
||||
$this->tpl->assign('user', $this->userService->getUser($id));
|
||||
$this->generateFormTokens();
|
||||
|
||||
return $this->tpl->display('api.delKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates CSRF form tokens for the delete confirmation form.
|
||||
*/
|
||||
private function generateFormTokens(): void
|
||||
{
|
||||
$permittedChars = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
session(['formTokenName' => substr(str_shuffle($permittedChars), 0, 32)]);
|
||||
session(['formTokenValue' => substr(str_shuffle($permittedChars), 0, 32)]);
|
||||
}
|
||||
}
|
||||
39
app/Domain/Api/Controllers/Goalcanvas.php
Normal file
39
app/Domain/Api/Controllers/Goalcanvas.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Goalcanvas class - Controller API
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
|
||||
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Goalcanvas extends Canvas
|
||||
{
|
||||
protected const CANVAS_NAME = 'goal';
|
||||
|
||||
/**
|
||||
* patch - inline goal-item update.
|
||||
*
|
||||
* Overrides the generic Canvas base so goal items are authorized with goals.* (the Goals
|
||||
* vocabulary) rather than the generic blueprints.* — the Goalcanvas service resolves the
|
||||
* item's real project and authorizes goals.edit before patching (throws 403 for a
|
||||
* missing/foreign item or insufficient role; false = no allowlisted columns).
|
||||
*/
|
||||
#[RequiresPermission(GoalcanvasPermissions::EDIT, entityScoped: true)]
|
||||
public function patch(array $params): Response
|
||||
{
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->displayJson(['status' => 'failure'], 400);
|
||||
}
|
||||
|
||||
if (app()->make(GoalcanvaService::class)->patchGoalItem((int) $params['id'], $params) === false) {
|
||||
return $this->tpl->displayJson(['status' => 'no valid fields to update'], 400);
|
||||
}
|
||||
|
||||
return $this->tpl->displayJson(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
51
app/Domain/Api/Controllers/I18n.php
Normal file
51
app/Domain/Api/Controllers/I18n.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Api\Services\I18n as I18nService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Class I18n
|
||||
*
|
||||
* This class handles attaching the language file to JavaScript.
|
||||
*/
|
||||
class I18n extends Controller
|
||||
{
|
||||
private I18nService $i18nService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(I18nService $i18nService): void
|
||||
{
|
||||
$this->i18nService = $i18nService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the language file to javascript
|
||||
*
|
||||
* @todo refactor to remove user timezone and timeformat and move to user settings
|
||||
*
|
||||
* @param array $params or body of the request.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$response = new Response(
|
||||
$this->i18nService->buildJsDictionary(),
|
||||
200
|
||||
);
|
||||
|
||||
$response->headers->set('Content-Type', 'application/javascript');
|
||||
$response->headers->set('Pragma', 'public');
|
||||
|
||||
// Disable cache for this file since datetime format settings is stored in here as well.
|
||||
// Need to find a better cache busting option for this.
|
||||
// $response->headers->set("Cache-Control", 'max-age=86400');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
562
app/Domain/Api/Controllers/Jsonrpc.php
Normal file
562
app/Domain/Api/Controllers/Jsonrpc.php
Normal file
@@ -0,0 +1,562 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Generates an JSON-RPC 2.0 API
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Exceptions\Contracts\LeantimeExceptionInterface;
|
||||
use Leantime\Core\Exceptions\MissingParameterException;
|
||||
use Leantime\Core\Http\Responses\JsonRpcErrorResponse;
|
||||
use Leantime\Core\Http\Responses\JsonRpcResponse;
|
||||
use Leantime\Core\Plugins\Attributes\RequiresPlugin;
|
||||
use Leantime\Domain\Plugins\Services\Plugins as PluginsService;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Jsonrpc extends Controller
|
||||
{
|
||||
private PermissionEnforcer $permissionEnforcer;
|
||||
|
||||
/**
|
||||
* init - initialize private variables or events to happen before route execution
|
||||
*/
|
||||
public function init(PermissionEnforcer $permissionEnforcer): void
|
||||
{
|
||||
$this->permissionEnforcer = $permissionEnforcer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles post requests
|
||||
*
|
||||
* @param array $params - value of $_POST
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
|
||||
// Remove act from params array
|
||||
if (isset($params['act'])) {
|
||||
unset($params['act']);
|
||||
}
|
||||
|
||||
// If params is empty, maybe it was in the body, get body
|
||||
if (empty($params)) {
|
||||
|
||||
try {
|
||||
$params = $this->getJsonFromBody();
|
||||
} catch (MissingParameterException $e) {
|
||||
Log::error($e);
|
||||
|
||||
return $this->returnMethodNotFound('Could not get any parameters from body');
|
||||
} catch (\JsonException $e) {
|
||||
Log::error($e);
|
||||
|
||||
return $this->returnParseError('Could not parse JSON. Error '.$e->getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// params['params'] could be array (single value) or json object
|
||||
if (isset($params['params'])) {
|
||||
if (! is_array($params['params'])) {
|
||||
$params['params'] = json_decode($params['params'], true);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->executeApiRequest($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles get requests
|
||||
*
|
||||
* @param array $params - value of $_GET
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
if (! isset($params['method'])) {
|
||||
return $this->returnInvalidRequest('Method name required');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode params
|
||||
*
|
||||
* @see https://www.jsonrpc.org/historical/json-rpc-over-http.html#get
|
||||
*/
|
||||
if (isset($params['params'])) {
|
||||
$paramsDecoded = base64_decode(urldecode($params['params']));
|
||||
} else {
|
||||
$paramsDecoded = [];
|
||||
}
|
||||
|
||||
$params = [
|
||||
'method' => $params['method'],
|
||||
'params' => $paramsDecoded,
|
||||
'id' => $params['id'] ?? null,
|
||||
'jsonrpc' => $params['jsonrpc'] ?? '',
|
||||
];
|
||||
|
||||
$params['params'] = json_decode($params['params'], true);
|
||||
|
||||
// check if decode failed
|
||||
if ($params == null) {
|
||||
return $this->returnParseError('JSON is invalid and was not able to be parsed');
|
||||
}
|
||||
|
||||
return $this->executeApiRequest($params);
|
||||
}
|
||||
|
||||
private function getJsonFromBody(): array
|
||||
{
|
||||
|
||||
if ($this->incomingRequest->server('REQUEST_METHOD') === 'POST'
|
||||
&& empty($_POST)
|
||||
&& $this->incomingRequest->getContent() !== null
|
||||
&& $this->incomingRequest->getContent() !== false
|
||||
&& $this->incomingRequest->getContent() !== '') {
|
||||
|
||||
$bodyContent = json_decode(
|
||||
json: $this->incomingRequest->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR
|
||||
);
|
||||
|
||||
return $bodyContent;
|
||||
}
|
||||
|
||||
throw new MissingParameterException('Could not get JSON from body or form fields');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles patch requests
|
||||
*/
|
||||
public function patch(): Response
|
||||
{
|
||||
return $this->returnInvalidRequest('The JSON-RPC API only supports POST/GET requests');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles delete requests
|
||||
*/
|
||||
public function delete(): Response
|
||||
{
|
||||
return $this->returnInvalidRequest('The JSON-RPC API only supports POST/GET requests');
|
||||
}
|
||||
|
||||
/**
|
||||
* executes api call
|
||||
*
|
||||
* @param array $params - request body
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
private function executeApiRequest(array $params): Response
|
||||
{
|
||||
/**
|
||||
* checks to see if array keys are incremented, if so, assume it's a batch request
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#batch
|
||||
*/
|
||||
if (array_keys($params) == range(0, count($params) - 1)) {
|
||||
|
||||
return new JsonResponse(array_map(
|
||||
function ($requestParams) {
|
||||
return json_decode($this->executeApiRequest($requestParams)->getContent());
|
||||
},
|
||||
$params
|
||||
));
|
||||
}
|
||||
|
||||
$id = $params['id'] ?? null;
|
||||
|
||||
try {
|
||||
$methodparts = $this->parseMethodString($params['method'] ?? '');
|
||||
} catch (Exception $e) {
|
||||
return $this->returnInvalidParams($e, $id);
|
||||
}
|
||||
|
||||
$jsonRpcVer = $params['jsonrpc'] ?? null;
|
||||
|
||||
$moduleName = Str::studly($methodparts['module']);
|
||||
$serviceName = Str::studly($methodparts['service']);
|
||||
|
||||
$domainServiceNamespace = app()->getNamespace()."Domain\\$moduleName\\Services\\$serviceName";
|
||||
$pluginServiceNamespace = app()->getNamespace()."Plugins\\$moduleName\\Services\\$serviceName";
|
||||
// Plugins may expose JSON-RPC methods from a Tools/ directory too — the same
|
||||
// directory McpToolDiscovery scans for #[UnifiedTool]-tagged classes. This
|
||||
// lets one class serve both MCP discovery and JSON-RPC dispatch without
|
||||
// moving the file or duplicating it under Services/.
|
||||
$pluginToolNamespace = app()->getNamespace()."Plugins\\$moduleName\\Tools\\$serviceName";
|
||||
|
||||
$methodName = Str::camel($methodparts['method']);
|
||||
|
||||
$paramsFromRequest = $params['params'] ?? [];
|
||||
|
||||
if (class_exists($domainServiceNamespace)) {
|
||||
$serviceName = $domainServiceNamespace;
|
||||
} elseif (class_exists($pluginServiceNamespace)) {
|
||||
$serviceName = $pluginServiceNamespace;
|
||||
} elseif (class_exists($pluginToolNamespace)) {
|
||||
$serviceName = $pluginToolNamespace;
|
||||
} else {
|
||||
return $this->returnMethodNotFound("Service doesn't exist: $serviceName", $id);
|
||||
}
|
||||
|
||||
if (! method_exists($serviceName, $methodName)) {
|
||||
return $this->returnMethodNotFound("Method doesn't exist: $methodName", $id);
|
||||
}
|
||||
|
||||
// Only allow methods explicitly marked with @api annotation
|
||||
if (! $this->isApiMethod($serviceName, $methodName)) {
|
||||
return $this->returnMethodNotFound("Method is not available via API: $methodName", $id);
|
||||
}
|
||||
|
||||
// Enforce plugin-gated methods. Methods or classes carrying #[RequiresPlugin('Name')]
|
||||
// refuse to dispatch when the named plugin is disabled — return a JSON-RPC error
|
||||
// with HTTP 200 body, mirroring the returnMethodNotFound pattern above.
|
||||
//
|
||||
// Uses the Domain Plugins service (DB-backed user plugins) rather than
|
||||
// Core\Plugins\Plugins (env-driven system plugins only). This matches what
|
||||
// config.getSystemInfo reports, so the gate and the client-visible capability
|
||||
// list share one source of truth.
|
||||
$requiredPlugin = $this->getRequiredPlugin($serviceName, $methodName);
|
||||
if ($requiredPlugin !== null && ! app()->make(PluginsService::class)->isEnabled($requiredPlugin)) {
|
||||
return $this->returnError(
|
||||
"Plugin '$requiredPlugin' is required but not enabled.",
|
||||
-32004,
|
||||
null,
|
||||
$id
|
||||
);
|
||||
}
|
||||
|
||||
if ($jsonRpcVer == null) {
|
||||
return $this->returnInvalidRequest('You must include a "jsonrpc" parameter with a value of "2.0"', $id);
|
||||
}
|
||||
|
||||
if ($jsonRpcVer !== '2.0') {
|
||||
return $this->returnInvalidRequest('Leantime only supports JSON-RPC version 2.0', $id);
|
||||
}
|
||||
|
||||
try {
|
||||
$methodParams = $this->getMethodParameters($serviceName, $methodName);
|
||||
} catch (\ReflectionException $e) {
|
||||
return $this->returnServerError("Error getting parameters: $e", $id);
|
||||
}
|
||||
|
||||
try {
|
||||
$preparedParams = $this->prepareParameters($paramsFromRequest, $methodParams);
|
||||
} catch (Exception $e) {
|
||||
return $this->returnInvalidParams($e, $id);
|
||||
}
|
||||
|
||||
// can be null
|
||||
try {
|
||||
// RPC bypasses the controller gate, so per-method authorization is enforced here:
|
||||
// a #[RequiresPermission] on the resolved service method is checked before the call.
|
||||
// A denial throws AuthorizationException, mapped below to JSON-RPC -32001.
|
||||
$this->permissionEnforcer->enforce($serviceName, $methodName, is_array($paramsFromRequest) ? $paramsFromRequest : []);
|
||||
|
||||
$method_response = app()->make($serviceName)->$methodName(...$preparedParams);
|
||||
} catch (\Throwable $e) {
|
||||
// Leantime exceptions carry a client-safe code/message/data and map to a precise
|
||||
// JSON-RPC error. Anything else is an unexpected failure that must be logged and
|
||||
// collapsed to a generic server error so internal detail never reaches the caller.
|
||||
if (! $e instanceof LeantimeExceptionInterface) {
|
||||
Log::error($e);
|
||||
}
|
||||
|
||||
// A notification (no id) must not be responded to, even on failure, per the
|
||||
// JSON-RPC 2.0 spec — mirror the success path's empty 200.
|
||||
if ($id === null) {
|
||||
return new Response('', Response::HTTP_OK);
|
||||
}
|
||||
|
||||
return JsonRpcErrorResponse::fromException($e, $id)->toResponse($this->incomingRequest);
|
||||
}
|
||||
|
||||
// Convert objects to associative arrays for JSON serialization, but pass
|
||||
// scalars and arrays through as-is. The previous `settype($var, 'array')`
|
||||
// coerced scalars to `[$scalar]`, which broke RPC methods returning ints
|
||||
// (e.g., addTicket returning a new ticket ID).
|
||||
if ($method_response !== null && is_object($method_response)) {
|
||||
$method_response = (array) $method_response;
|
||||
}
|
||||
|
||||
return $this->returnResponse($method_response, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the method string
|
||||
*
|
||||
* @param string $methodstring - leantime.rpc.service.method
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function parseMethodString(string $methodstring): array
|
||||
{
|
||||
if (empty($methodstring)) {
|
||||
throw new Exception('Must include method');
|
||||
}
|
||||
|
||||
if (! str_starts_with($methodstring, 'leantime.rpc.')) {
|
||||
throw new Exception("Method string doesn't start with \"leantime.rpc.\"");
|
||||
}
|
||||
|
||||
// method parameter breakdown
|
||||
// 00000000.111.22222222.3333333333333.444444444444
|
||||
// leantime.rpc.{module}.{servicename}.{methodname}
|
||||
$methodStringPieces = explode('.', $methodstring);
|
||||
|
||||
if (count($methodStringPieces) !== 4 && count($methodStringPieces) !== 5) {
|
||||
throw new Exception('Method is case sensitive and must follow the following naming convention: "leantime.rpc.{domain}.{servicename}.{methodname}"');
|
||||
}
|
||||
|
||||
if (count($methodStringPieces) === 4) {
|
||||
return [
|
||||
'module' => $methodStringPieces[2],
|
||||
'service' => $methodStringPieces[2],
|
||||
'method' => $methodStringPieces[3],
|
||||
];
|
||||
}
|
||||
|
||||
if (count($methodStringPieces) === 5) {
|
||||
return [
|
||||
'module' => $methodStringPieces[2],
|
||||
'service' => $methodStringPieces[3],
|
||||
'method' => $methodStringPieces[4],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a service method is marked with the @api annotation.
|
||||
*
|
||||
* @param string $serviceName Fully qualified class name
|
||||
* @param string $methodName Method name
|
||||
* @return bool True if the method has an @api docblock tag
|
||||
*/
|
||||
private function isApiMethod(string $serviceName, string $methodName): bool
|
||||
{
|
||||
try {
|
||||
$reflection = new ReflectionMethod($serviceName, $methodName);
|
||||
$docComment = $reflection->getDocComment();
|
||||
|
||||
if ($docComment === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match the @api tag only at the START of a docblock line (" * @api"), so an
|
||||
// explanatory prose mention (e.g. "@internal not exposed via JSON-RPC, unlike @api
|
||||
// methods") can never accidentally re-expose a deliberately-internal method.
|
||||
return (bool) preg_match('/^\s*\*\s*@api\b/m', $docComment);
|
||||
} catch (\ReflectionException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the plugin name a method (or its declaring class) requires, if any.
|
||||
*
|
||||
* Looks for the RequiresPlugin attribute on the method first, then the class.
|
||||
* Method-level wins over class-level.
|
||||
*
|
||||
* @return string|null The required plugin folder name, or null if not gated
|
||||
*/
|
||||
private function getRequiredPlugin(string $serviceName, string $methodName): ?string
|
||||
{
|
||||
try {
|
||||
$method = new ReflectionMethod($serviceName, $methodName);
|
||||
$attrs = $method->getAttributes(RequiresPlugin::class);
|
||||
if (! empty($attrs)) {
|
||||
return $attrs[0]->newInstance()->pluginName;
|
||||
}
|
||||
|
||||
$class = new ReflectionClass($serviceName);
|
||||
$classAttrs = $class->getAttributes(RequiresPlugin::class);
|
||||
if (! empty($classAttrs)) {
|
||||
return $classAttrs[0]->newInstance()->pluginName;
|
||||
}
|
||||
} catch (\ReflectionException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Method Parameters
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
private function getMethodParameters(string $servicename, string $methodname): array
|
||||
{
|
||||
return (new ReflectionClass($servicename))
|
||||
->getMethod($methodname)
|
||||
->getParameters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks request params
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function prepareParameters(array $params, array $methodParams): array
|
||||
{
|
||||
$filtered_parameters = [];
|
||||
|
||||
// matches params, params that don't match are ignored
|
||||
foreach ($methodParams as $methodParam) {
|
||||
$required = ! $methodParam->isDefaultValueAvailable();
|
||||
$position = $methodParam->getPosition();
|
||||
$name = $methodParam->name;
|
||||
$type = $methodParam->getType();
|
||||
|
||||
// check if param is there
|
||||
if (! in_array($name, array_keys($params))) {
|
||||
if ($required) {
|
||||
throw new Exception("Required Parameter Missing: $name");
|
||||
}
|
||||
|
||||
$filtered_parameters[$position] = $methodParam->getDefaultValue();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if type is correct or can be correct
|
||||
if ($methodParam->hasType()) {
|
||||
if (in_array($type, [gettype($params[$name]), 'mixed'])) {
|
||||
$filtered_parameters[$position] = $params[$name];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($params[$name] === null && ! $type->allowsNull()) {
|
||||
throw new Exception("Parameter $name can't be null");
|
||||
}
|
||||
|
||||
try {
|
||||
$filtered_parameters[$position] = cast($params[$name], $type->getName());
|
||||
} catch (\Throwable $e) {
|
||||
Log::error($e);
|
||||
throw new \Exception("Could not cast parameter: $name. See server logs for more details.");
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($filtered_parameters[$position])) {
|
||||
$filtered_parameters[$position] = $params[$name];
|
||||
}
|
||||
}
|
||||
|
||||
// make sure it is in the right order
|
||||
ksort($filtered_parameters);
|
||||
|
||||
return $filtered_parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Echos the return response.
|
||||
*
|
||||
* @param mixed $returnValue The return value from the RPC method. Widened from
|
||||
* `?array` because the upstream `settype` coercion that
|
||||
* wrapped scalars into single-element arrays was removed
|
||||
* (it broke methods returning ints — e.g. addTicket's
|
||||
* new ticket id was being delivered as [id]). Per the
|
||||
* JSON-RPC 2.0 spec §5, `result` MAY be any JSON value;
|
||||
* caller code in `executeRPC` already casts objects to
|
||||
* associative arrays before reaching here, so in practice
|
||||
* this is array|scalar|null.
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#response_object
|
||||
*/
|
||||
private function returnResponse(mixed $returnValue, int|string|null $id = null): Response
|
||||
{
|
||||
return (new JsonRpcResponse($returnValue, $id))->toResponse($this->incomingRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return error response
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnError(string $errorMessage, int $errorcode, mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
// Protocol-level callers (parse / invalid-request / method-not-found / invalid-params)
|
||||
// may pass their own thrown exception for context; surface only its message. Service-
|
||||
// level exceptions never reach here — they go through JsonRpcErrorResponse::fromException().
|
||||
$data = $additional_info instanceof \Throwable ? $additional_info->getMessage() : $additional_info;
|
||||
|
||||
return (new JsonRpcErrorResponse($errorcode, $errorMessage, $data, $id))->toResponse($this->incomingRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parse error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnParseError(mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Parse error', -32700, $additional_info, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an invalid request error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnInvalidRequest(mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Invalid Request', -32600, $additional_info, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a method not found error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnMethodNotFound(mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Method not found', -32601, $additional_info, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an invalid parameters error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnInvalidParams(mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Invalid params', -32602, $additional_info, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a server error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*
|
||||
* @param mixed|null $additional_info
|
||||
*/
|
||||
private function returnServerError(mixed $additional_info, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Server error', -32000, $additional_info, $id);
|
||||
}
|
||||
}
|
||||
114
app/Domain/Api/Controllers/NewApiKey.php
Normal file
114
app/Domain/Api/Controllers/NewApiKey.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Api\Services\Api as ApiService;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NewApiKey extends Controller
|
||||
{
|
||||
private ApiService $APIService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function init(ApiService $APIService): void
|
||||
{
|
||||
self::dispatch_event('api_key_init', $this);
|
||||
|
||||
$this->APIService = $APIService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the new API key form.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
if (! Auth::userIsAtLeast(Roles::$admin)) {
|
||||
return $this->tpl->displayPartial('errors.error403');
|
||||
}
|
||||
|
||||
$values = [
|
||||
'firstname' => '',
|
||||
'lastname' => '',
|
||||
'user' => '',
|
||||
'role' => '',
|
||||
'password' => '',
|
||||
'status' => 'a',
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
$this->tpl->assign('values', $values);
|
||||
$this->tpl->assign('allProjects', $this->APIService->getAllProjects());
|
||||
$this->tpl->assign('roles', Roles::getRoles());
|
||||
$this->tpl->assign('relations', []);
|
||||
|
||||
return $this->tpl->displayPartial('api.newAPIKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles API key creation.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
if (! Auth::userIsAtLeast(Roles::$admin)) {
|
||||
return $this->tpl->displayPartial('errors.error403');
|
||||
}
|
||||
|
||||
$values = [
|
||||
'firstname' => '',
|
||||
'lastname' => '',
|
||||
'user' => '',
|
||||
'role' => '',
|
||||
'password' => '',
|
||||
'status' => 'a',
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
$projectRelation = [];
|
||||
|
||||
if (isset($_POST['save'])) {
|
||||
$values = [
|
||||
'firstname' => ($_POST['firstname']),
|
||||
'user' => '',
|
||||
'role' => ($_POST['role']),
|
||||
'password' => '',
|
||||
'pwReset' => '',
|
||||
'status' => '',
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
$projectRelation = (isset($_POST['projects']) && is_array($_POST['projects'])) ? $_POST['projects'] : [];
|
||||
|
||||
$apiKeyValues = $this->APIService->createApiKeyWithProjects($values, $_POST['projects'] ?? null);
|
||||
|
||||
$this->tpl->setNotification('notifications.key_created', 'success', 'apikey_created');
|
||||
$this->tpl->assign('apiKeyValues', $apiKeyValues);
|
||||
}
|
||||
|
||||
$this->tpl->assign('values', $values);
|
||||
$this->tpl->assign('allProjects', $this->APIService->getAllProjects());
|
||||
$this->tpl->assign('roles', Roles::getRoles());
|
||||
$this->tpl->assign('relations', $projectRelation);
|
||||
|
||||
return $this->tpl->displayPartial('api.newAPIKey');
|
||||
}
|
||||
}
|
||||
69
app/Domain/Api/Controllers/StaticAsset.php
Normal file
69
app/Domain/Api/Controllers/StaticAsset.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Domain\Api\Contracts\StaticAssetType;
|
||||
use Leantime\Domain\Api\Services\Api as ApiService;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StaticAsset extends Controller
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
private Environment $config;
|
||||
|
||||
private ApiService $apiService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(Environment $config, ApiService $apiService): void
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->apiService = $apiService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the static asset by path.
|
||||
*
|
||||
*
|
||||
* @param array $params parameters or body of the request
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$debug = (bool) $this->config->get('debug', false);
|
||||
|
||||
$asset = $this->apiService->resolveStaticAsset($this->incomingRequest->getPathInfo(), $debug);
|
||||
|
||||
if ($asset === false) {
|
||||
return new Response('', 404);
|
||||
}
|
||||
|
||||
/** @var StaticAssetType $type */
|
||||
$type = $asset['type'];
|
||||
|
||||
return tap(
|
||||
new BinaryFileResponse($asset['path']),
|
||||
function (BinaryFileResponse $response) use ($type, $debug) {
|
||||
$response->headers->set('Content-Type', StaticAssetType::getMimeTypeByExtension($type));
|
||||
// Only set Content-length when filesize() succeeds; on failure let
|
||||
// BinaryFileResponse compute it rather than advertising a bogus 0-length body.
|
||||
$size = filesize($response->getFile()->getPathname());
|
||||
if ($size !== false) {
|
||||
$response->headers->set('Content-length', (string) $size);
|
||||
}
|
||||
|
||||
if (in_array(true, [! $this->incomingRequest->query->has('id'), $debug])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response->headers->set('Cache-Control', 'public, max-age=86500, immutable');
|
||||
$response->headers->set('Pragma', 'public');
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user