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

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

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

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

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

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

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

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