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,103 @@
<?php
namespace Leantime\Domain\Api\Contracts;
enum StaticAssetType: string
{
case AAC = 'audio/aac';
case ABW = 'application/x-abiword';
case ARC = 'application/x-freearc';
case AVI = 'video/x-msvideo';
case AZW = 'application/vnd.amazon.ebook';
case BIN = 'application/octet-stream';
case BMP = 'image/bmp';
case BZ = 'application/x-bzip';
case BZ2 = 'application/x-bzip2';
case CSH = 'application/x-csh';
case CSS = 'text/css';
case CSV = 'text/csv';
case DOC = 'application/msword';
case DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
case EOT = 'application/vnd.ms-fontobject';
case EPUB = 'application/epub+zip';
case GIF = 'image/gif';
case GZ = 'application/gzip';
case HTM = 'HTML';
case HTML = 'text/html';
case ICO = 'image/vnd.microsoft.icon';
case ICS = 'text/calendar';
case JAR = 'application/java-archive';
case JPEG = 'JPG';
case JPG = 'image/jpeg';
case JS = 'text/javascript';
case JSON = 'application/json';
case JSONLD = 'application/ld+json';
case MD = 'text/markdown';
case MID = 'MIDI';
case MIDI = 'audio/midi';
case MJS = 'JS';
case MP3 = 'audio/mpeg';
case MPEG = 'video/mpeg';
case MPKG = 'application/vnd.apple.installer+xml';
case ODP = 'application/vnd.oasis.opendocument.presentation';
case ODS = 'application/vnd.oasis.opendocument.spreadsheet';
case ODT = 'application/vnd.oasis.opendocument.text';
case OGA = 'audio/ogg';
case OGV = 'video/ogg';
case OGX = 'application/ogg';
case OPUS = 'audio/opus';
case OTF = 'font/otf';
case PDF = 'application/pdf';
case PNG = 'image/png';
case PPT = 'application/vnd.ms-powerpoint';
case PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
case RAR = 'application/vnd.rar';
case RTF = 'application/rtf';
case SVG = 'image/svg+xml';
case TAR = 'application/x-tar';
case TIF = 'TIFF';
case TIFF = 'image/tiff';
case TS = 'video/mp2t';
case TTF = 'font/ttf';
case TXT = 'text/plain';
case VSD = 'application/vnd.visio';
case WAV = 'audio/wav';
case WEBA = 'audio/webm';
case WEBM = 'video/webm';
case WEBP = 'image/webp';
case WOFF = 'font/woff';
case WOFF2 = 'font/woff2';
case XHTML = 'application/xhtml+xml';
case XLS = 'application/vnd.ms-excel';
case XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case XML = 'application/xml';
case XUL = 'application/vnd.mozilla.xul+xml';
case YAML = 'YML';
case YML = 'text/yaml';
case ZIP = 'application/zip';
/**
* Retrieves the MIME type by extension.
*
* @param StaticAssetType $extension The file extension to get the MIME type for.
* @return string The MIME type associated with the given extension.
*/
public static function getMimeTypeByExtension(StaticAssetType $extension): string
{
if (in_array($value = $extension->value, self::getFileExtensions())) {
$value = constant("self::$value")->value;
}
return $value;
}
/**
* Retrieves the file extensions.
*
* @return array Array of file extensions.
*/
public static function getFileExtensions(): array
{
return array_map(fn ($case) => $case->name, self::cases());
}
}

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

View File

@@ -0,0 +1,71 @@
var leantime = leantime || {};
/**
* Shared JSON-RPC 2.0 client.
*
* Calls a service method exposed via the '/api/jsonrpc' endpoint, addressed as
* leantime.rpc.{Module}.{Service}.{method}. The endpoint is CSRF-exempt and
* authenticates via the session cookie, so we only send X-Requested-With.
*
* Only service methods annotated with @api are callable (the endpoint enforces this).
*
* Usage:
* const result = await leantime.rpc('Tickets.Tickets.patchTicket', { id: 5, values: { status: 3 } });
*/
leantime.jsonrpc = (function () {
/**
* Invoke a single JSON-RPC method.
*
* @param {string} method - dotted path WITHOUT the leantime.rpc prefix, e.g. 'Tickets.Tickets.patchTicket'
* @param {object} params - named parameters matched by name to the service method signature
* @param {object} options - { id, signal } optional request id and AbortSignal
* @returns {Promise<*>} resolves to the service return value, rejects with an Error {code, data} on RPC error
*/
async function call(method, params, options) {
params = params || {};
options = options || {};
const response = await fetch(leantime.appUrl + '/api/jsonrpc', {
method: 'POST',
credentials: 'include',
signal: options.signal,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'leantime.rpc.' + method,
params: params,
id: typeof options.id !== 'undefined' ? options.id : 1,
}),
});
if (!response.ok) {
const httpError = new Error('JSON-RPC request failed with HTTP ' + response.status);
httpError.code = response.status;
throw httpError;
}
const data = await response.json();
if (data && data.error) {
const rpcError = new Error(data.error.message || 'JSON-RPC error');
rpcError.code = data.error.code;
rpcError.data = data.error.data;
throw rpcError;
}
return data ? data.result : undefined;
}
return { call: call };
})();
/**
* Convenience alias: leantime.rpc('Module.Service.method', params, options) -> Promise.
*/
leantime.rpc = function (method, params, options) {
return leantime.jsonrpc.call(method, params, options);
};

View File

@@ -0,0 +1,17 @@
<?php
namespace Leantime\Domain\Api\Models;
use Leantime\Domain\Api\Contracts\StaticAssetType;
/**
* Represents a static asset file.
*/
class StaticAsset
{
public function __construct(
public string $key,
public string $absPath,
public StaticAssetType $fileType,
) {}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Leantime\Domain\Api\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The API (key management) permission vocabulary — the verbs only.
*
* Leantime API keys act as service accounts (a key IS a user row with a role), so creating,
* listing, and editing them is an installation-wide administrative capability — the management
* UI (ApiKey / NewApiKey / DelAPIKey controllers) is already `authOrRedirect([owner, admin])`.
* The single verb below is therefore COMPANY-WIDE (`projectScoped = false`); call sites gate with
* `#[RequiresPermission(ApiPermissions::MANAGE, global: true)]`, which by the default role map
* lands on admin/owner only.
*
* Note: authenticating WITH an existing key (getAPIKeyUser) is not gated by this — that is the
* auth primitive itself, invoked by the AuthCheck middleware, not a management action.
*/
final class ApiPermissions implements ProvidesPermissions
{
/** Create, list, edit, or remove API keys / service-account credentials (company-wide). */
public const MANAGE = 'api.manage';
public function domain(): string
{
return 'api';
}
public function permissions(): array
{
return [
new Permission(self::MANAGE, 'Manage API keys', false),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Leantime\Domain\Api\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\Db as DbCore;
class Api
{
private ConnectionInterface $db;
public function __construct(DbCore $db)
{
$this->db = $db->getConnection();
}
public function getAPIKeyUser(string $apiKeyUser): mixed
{
$result = $this->db->table('zp_user')
->where('username', $apiKeyUser)
->where('source', 'api')
->limit(1)
->first();
return $result ? (array) $result : false;
}
}

View File

@@ -0,0 +1,524 @@
<?php
namespace Leantime\Domain\Api\Services;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Events\DispatchesEvents;
use Leantime\Domain\Api\Contracts\StaticAssetType;
use Leantime\Domain\Api\Permissions\ApiPermissions;
use Leantime\Domain\Api\Repositories\Api as ApiRepository;
use Leantime\Domain\Auth\Services\UserSessionBuilder;
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use RangeException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class Api
{
use DispatchesEvents;
private ApiRepository $apiRepository;
private UserRepository $userRepo;
private ProjectRepository $projectRepo;
private MenuRepository $menuRepo;
private ?array $error = null;
/**
* @api
*/
public function __construct(
ApiRepository $apiRepository,
UserRepository $userRepo,
ProjectRepository $projectRepo,
MenuRepository $menuRepo
) {
$this->apiRepository = $apiRepository;
$this->userRepo = $userRepo;
$this->projectRepo = $projectRepo;
$this->menuRepo = $menuRepo;
}
/**
* @throws BindingResolutionException
*
* @api
*/
public function getAPIKeyUser(string $apiKey): bool|array
{
// Split apiKey into parts
$apiKeyParts = explode('_', $apiKey);
if (! is_array($apiKeyParts) || count($apiKeyParts) != 3) {
return false;
}
$namespace = $apiKeyParts[0];
$user = $apiKeyParts[1];
$key = $apiKeyParts[2];
if ($namespace != 'lt') {
return false;
}
$apiUser = $this->apiRepository->getAPIKeyUser($user);
if ($apiUser) {
if (password_verify($key, $apiUser['password'])) {
$this->setApiUserSession($apiUser, true);
return $apiUser;
}
}
return false;
}
/**
* @return void
*
* @throws BindingResolutionException
*
* Note: This is deliberately a duplicate of the authService setSession method to not have to load the authService
* which will run db connections when we are not ready yet.
* TODO: Move session management into a dedicated service
*/
public function setApiUserSession(array $user, bool $isExternalAuth = false)
{
// x-api-key (and Bearer fallback) session. twoFAVerified: true — like the Sanctum-token
// path, an API token is the strong credential and no interactive 2FA is possible (this
// was previously false, diverging from the AuthUser/Bearer builder). Built via the shared
// factory so role + every field stay identical across all auth paths.
$currentUser = UserSessionBuilder::build($user, isExternalAuth: $isExternalAuth, twoFAVerified: true);
$currentUser = self::dispatch_filter('user_session_vars', $currentUser);
// Session handler for api is array
session(['userdata' => $currentUser]);
}
/**
* createAPIKey - simple service wrapper to create a new user
*
* TODO: Should accept userModel
*
* @param array $values basic user values
* @return bool|array returns new user id on success, false on failure
*
* @throws Exception
*
* @api
*/
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
public function createAPIKey(array $values): bool|array
{
$user = $this->randomStr(32);
$password = $this->randomStr(32);
$values['user'] = $user;
$values['lastname'] = '';
$values['passwordClean'] = $password;
$values['password'] = $password;
$values['status'] = 'a';
$values['clientId'] = '';
$values['phone'] = '';
$values['id'] = $this->userRepo->addUser($values);
return $values['id'] ? $values : false;
}
/**
* Loads the stored values of an existing API key (user row) and maps them
* into the value array used by the API key edit form.
*
* @param int $id API key (user) id
* @return array Mapped value array
*
* @throws Exception When the id is not a positive integer
*
* @api
*/
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
public function getApiKeyFormValues(int $id): array
{
if ($id <= 0) {
throw new Exception('Invalid API key id');
}
$row = $this->userRepo->getUser($id);
return [
'firstname' => $row['firstname'],
'lastname' => $row['lastname'],
'user' => $row['username'],
'phone' => $row['phone'],
'status' => $row['status'],
'role' => $row['role'],
'hours' => $row['hours'],
'wage' => $row['wage'],
'clientId' => $row['clientId'],
'source' => $row['source'],
'pwReset' => $row['pwReset'],
];
}
/**
* Updates an existing API key and reconciles its project relations.
*
* The save values are intentionally normalized the same way the legacy
* controller did: only firstname/status/role are taken from the posted
* values, everything else is blanked and the source stays 'api'.
*
* @param int $id API key (user) id
* @param array $postValues Posted form values (firstname, status, role, ...)
* @param array|null $projects Selected project ids, or null when none submitted
*
* @throws Exception When the id is not a positive integer
*
* @api
*/
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
public function updateApiKey(int $id, array $postValues, ?array $projects): bool
{
if ($id <= 0) {
throw new Exception('Invalid API key id');
}
$row = $this->userRepo->getUser($id);
$values = [
'firstname' => ($postValues['firstname'] ?? $row['firstname']),
'lastname' => '',
'user' => $row['username'],
'phone' => '',
'status' => ($postValues['status'] ?? $row['status']),
'role' => ($postValues['role'] ?? $row['role']),
'hours' => '',
'wage' => '',
'clientId' => '',
'password' => '',
'source' => 'api',
'pwReset' => '',
];
$this->userRepo->editUser($values, $id);
$this->reconcileProjectRelations($id, $projects);
return true;
}
/**
* Creates a new API key and reconciles its project relations.
*
* @param array $values Basic user/key values (firstname, role, ...)
* @param array|null $projects Selected project ids, or null when none submitted
* @return array|false The created key values on success, false on failure
*
* @throws Exception
*
* @api
*/
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
public function createApiKeyWithProjects(array $values, ?array $projects): array|false
{
$apiKeyValues = $this->createAPIKey($values);
if ($apiKeyValues === false) {
return false;
}
if (is_array($projects) && count($projects) > 0) {
$this->reconcileProjectRelations((int) $apiKeyValues['id'], $projects);
}
return $apiKeyValues;
}
/**
* Reconciles the project relations for an API key (user).
*
* Mirrors the legacy controller behaviour: a leading "0" selection (or no
* selection at all) clears all relations, otherwise the relations are set.
*
* @param int $id API key (user) id
* @param array|null $projects Selected project ids
*/
private function reconcileProjectRelations(int $id, ?array $projects): void
{
if (is_array($projects) && isset($projects[0]) && $projects[0] !== '0') {
$this->projectRepo->editUserProjectRelations($id, $projects);
return;
}
$this->projectRepo->deleteAllProjectRelations($id);
}
/**
* Returns the list of project ids an API key (user) is related to.
*
* @param int $id API key (user) id
* @return array List of project ids
*
* @api
*/
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
public function getProjectRelationIds(int $id): array
{
$projects = $this->projectRepo->getUserProjectRelation($id);
$relations = [];
foreach ($projects as $projectId) {
$relations[] = $projectId['projectId'];
}
return $relations;
}
/**
* Returns all projects (for populating the API key form selectors).
*
* @api
*/
public function getAllProjects(): array
{
return $this->projectRepo->getAll();
}
/**
* Returns the list of valid API key (user) status values.
*
* @api
*/
public function getUserStatusOptions(): array
{
return $this->userRepo->status;
}
/**
* Generates a new form (CSRF) token and stores it in the session so the
* API key form can validate the subsequent submission.
*
* @api
*/
public function generateFormToken(): void
{
$permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyz';
session(['formTokenName' => substr(str_shuffle($permitted_chars), 0, 32)]);
session(['formTokenValue' => substr(str_shuffle($permitted_chars), 0, 32)]);
}
/**
* getAPIKeys - gets api keys (users) from user table
*
*
*
* @api
*/
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
public function getAPIKeys(): false|array
{
$keys = $this->userRepo->getAllBySource('api');
foreach ($keys as &$key) {
$key['username'] = substr($key['username'], 0, 5);
}
return $keys;
}
/**
* Generate a random string, using a cryptographically secure
* pseudorandom number generator (random_int)
*
* This function uses type hints now (PHP 7+ only), but it was originally
* written for PHP 5 as well.
*
* For PHP 7, random_int is a PHP core function
* For PHP 5.x, depends on https://github.com/paragonie/random_compat
*
* @param int $length How many characters do we want?
* @param string $keyspace A string of all possible characters to select from
*
* @throws Exception
*
* @api
*/
public function randomStr(
int $length = 64,
string $keyspace = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
): string {
if ($length < 1) {
throw new RangeException('Length must be a positive integer');
}
$pieces = [];
$max = mb_strlen($keyspace, '8bit') - 1;
for ($i = 0; $i < $length; $i++) {
$pieces[] = $keyspace[random_int(0, $max)];
}
return implode('', $pieces);
}
/**
* @todo Remove this.
*
* @see ../Controllers/Tickets.php
*
* @api
*/
public function jsonResponse(int $id, ?array $result): void
{
$jsonRPCArray = [
'jsonrpc' => '2.0',
];
header('Content-Type: application/json; charset=utf-8');
if ($this->error != null) {
$jsonRPCArray['error'] = $this->error;
} elseif ($result !== null) {
$jsonRPCArray['result'] = $result;
}
echo json_encode($jsonRPCArray);
}
/**
* Check the manifest for the asset and serve if found.
*
* @api
*/
public function getCaseCorrectPathFromManifest(string $filepath): string|false
{
$manifest = mix('')->getManifest();
$clone = array_change_key_case(collect(Arr::dot($manifest))
->mapWithKeys(fn ($value, $key) => [Str::of($key)->replaceFirst('./', '/')->lower()->toString() => $value])
->all());
if (is_null($referenceValue = $clone[strtolower($filepath)] ?? null)) {
return false;
}
$correctManifest = array_filter($manifest, fn ($arr) => in_array($referenceValue, $arr));
$basePath = array_keys($correctManifest)[0];
$correctManifest = array_values($correctManifest)[0];
return $basePath.array_search($referenceValue, $correctManifest);
}
/**
* Resolves a static asset request path into an on-disk path and its
* asset type.
*
* Maps the request URI to the filesystem app path, validates the extension
* against the StaticAssetType enum, rewrites phar paths, and resolves the
* case-correct path via the mix manifest.
*
* @param string $pathInfo The request path info (e.g. /api/static-asset/...)
* @param bool $debug Whether debug mode is enabled (affects failure behaviour)
* @return array{path: string, type: StaticAssetType}|false The resolved asset, or false on failure
*
* @throws BadRequestHttpException When the extension is not a known asset type and debug is on
* @throws NotFoundHttpException When the asset is not found in the manifest and debug is on
*
* @api
*/
public function resolveStaticAsset(string $pathInfo, bool $debug = false): array|false
{
$fullpath = Str::of($pathInfo)
->replaceFirst('/api/static-asset/', APP_ROOT.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR)
->replace('/', DIRECTORY_SEPARATOR)
->lower();
// Check if it's a static asset
if (! defined($constant = StaticAssetType::class.'::'.$fullpath->afterLast('.')->upper())) {
if ($debug) {
throw new BadRequestHttpException;
}
return false;
}
if (Str::contains($fullpath, '.phar') && ! Str::startsWith($fullpath, 'phar://')) {
$fullpath = 'phar://'.$fullpath;
}
/** @var StaticAssetType $type */
$type = constant($constant);
if (! $correctPath = $this->getCaseCorrectPathFromManifest((string) $fullpath)) {
if ($debug) {
throw new NotFoundHttpException;
}
return false;
}
return [
'path' => $correctPath,
'type' => $type,
];
}
/**
* Persists the collapsed/expanded state of a submenu.
*
* @param string $submenu Submenu identifier
* @param string $state Submenu state
*
* @api
*/
public function setSubmenuState(string $submenu, string $state): void
{
$this->menuRepo->setSubmenuState($submenu, $state);
}
/**
* Persists the main menu state both in the session and the menu store.
*
* @param string $state Raw main menu state from the request
*
* @api
*/
public function setMainMenuState(string $state): void
{
session(['menuState' => htmlentities($state)]);
$this->menuRepo->setSubmenuState('mainMenu', $state);
}
/**
* Persists whether the product tour is active in the session.
*
* @param mixed $tourActive Raw tour flag from the request
*
* @api
*/
public function setTourActive($tourActive): void
{
session(['tourActive' => filter_var($tourActive, FILTER_SANITIZE_NUMBER_INT)]);
}
/**
* @return true
*/
public function healthCheck()
{
return true;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Leantime\Domain\Api\Services;
use Leantime\Core\Configuration\AppSettings;
use Leantime\Domain\Plugins\Services\Plugins as PluginsService;
/**
* Core capability-discovery service for JSON-RPC clients (mobile, MCP, web).
*
* Always available regardless of which plugins are installed — paired with the
* RequiresPlugin attribute so clients can gate UI client-side instead of discovering
* disabled capabilities through failed RPC calls.
*
* Capability staleness: client-side cache should refresh on next login. Admin toggles
* propagate on next session; up-to-session-length staleness is acceptable.
*
* @api
*/
class Config
{
public function __construct(
private AppSettings $appSettings,
private PluginsService $pluginsService,
) {}
/**
* Return system version + enabled-plugin list for capability discovery.
*
* @return array{version: string, enabledPlugins: array<int, string>}
*
* @api
*/
public function getSystemInfo(): array
{
$enabled = $this->pluginsService->getEnabledPlugins() ?: [];
$pluginFolders = [];
foreach ($enabled as $plugin) {
$folder = is_object($plugin) ? ($plugin->foldername ?? null) : ($plugin['foldername'] ?? null);
if ($folder) {
$pluginFolders[] = $folder;
}
}
return [
'version' => $this->appSettings->appVersion,
'enabledPlugins' => $pluginFolders,
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Leantime\Domain\Api\Services;
use Leantime\Core\Language;
/**
* Class I18n
*
* Assembles the i18n dictionary payload that is exposed to JavaScript.
*/
class I18n
{
private Language $language;
/**
* @api
*/
public function __construct(Language $language)
{
$this->language = $language;
}
/**
* Builds the JavaScript snippet that defines the global leantime.i18n object,
* including the language dictionary, the resolved date/time format strings and
* the user timezone.
*
* @return string The JavaScript payload
*
* @api
*/
public function buildJsDictionary(): string
{
$languageIni = $this->language->ini_array;
$dateTimeIniSettings = [
'language.dateformat',
'language.timeformat',
];
foreach ($dateTimeIniSettings as $index) {
$languageIni[$index] = $this->language->__($index);
}
// Fullcalendar and other scripts can handle local to use the browser timezone
$languageIni['usersettings.timezone'] = session('usersettings.timezone') ?? 'local';
$decodedString = json_encode($languageIni);
$result = $decodedString ? $decodedString : '{}';
return <<<JS
var leantime = leantime || {};
var leantime = {
i18n: {
dictionary: $result,
__: function(index){ return leantime.i18n.dictionary[index]; }
}
};
JS;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Leantime\Domain\Api\Services;
use Leantime\Domain\Ideas\Repositories\Ideas as IdeasRepository;
/**
* Internal shim wrapping idea data access for the legacy Api Ideation controller.
*
* NOT exposed via JSON-RPC: these methods operate by item id with no project
* scoping. Idea mutations from the frontend now go through the authorized
* wrappers on Leantime\Domain\Ideas\Services\Ideas (reorderIdeas /
* bulkUpdateStatus / patchIdeaItem), which enforce editor + project access.
*
* @deprecated Will be removed once the Api Ideation controller is gone.
*/
class Ideas
{
private IdeasRepository $ideasRepository;
public function __construct(IdeasRepository $ideasRepository)
{
$this->ideasRepository = $ideasRepository;
}
public function updateIdeaSorting($payload): bool
{
return $this->ideasRepository->updateIdeaSorting($payload);
}
public function bulkUpdateIdeaStatus($payload): bool
{
return $this->ideasRepository->bulkUpdateIdeaStatus($payload);
}
public function updateIdeationSorting($payload): bool
{
return $this->ideasRepository->updateIdeaSorting($payload);
}
public function bulkUpdateIdeationStatus($payload): bool
{
return $this->ideasRepository->bulkUpdateIdeaStatus($payload);
}
public function patchCanvasItem(int $id, array $params): bool
{
return $this->ideasRepository->patchCanvasItem($id, $params);
}
}

View File

@@ -0,0 +1,142 @@
@extends($layout)
@section('content')
<div style="min-width:700px;">
<h4 class="widgettitle title-light"><i class="fa fa-key"></i> {!! __('headlines.api_key') !!}</h4>
{!! $tpl->displayNotification() !!}
<form action="{{ BASE_URL }}/api/apiKey/{{ (int) $_GET['id'] }}" method="post" class="stdform formModal" >
<input type="hidden" name="{{ session('formTokenName') }}" value="{{ session('formTokenValue') }}" />
<input type="hidden" name="save" value="1" />
<div class="row" >
<div class="col-md-6">
<h4 class="widgettitle title-light">{!! __('label.basic_information') !!}</h4>
<label>{!! __('label.key') !!}</label><div class="clearfix"></div>
lt_{{ substr($values['user'], 0, 5) }}***<br /><br />
<label for="firstname">{!! __('label.key_name') !!}</label><div class="clearfix"></div>
<x-global::forms.text-input
name="firstname" id="firstname"
value="{{ $values['firstname'] }}" /><br />
<label for="role">{!! __('label.role') !!}</label><div class="clearfix"></div>
<select name="role" id="role">
@foreach ($roles as $key => $role)
<option value="{{ $key }}"
@if ($key == $values['role'])
selected="selected"
@endif
>
{!! __('label.roles.' . $role) !!}
</option>
@endforeach
</select> <br />
<label for="status">{!! __('label.status') !!}</label><div class="clearfix"></div>
<select name="status" id="status">
<option value="a"
@if (strtolower($values['status']) == 'a')
selected="selected"
@endif
>
{!! __('label.active') !!}
</option>
<option value=""
@if (strtolower($values['status']) == '')
selected="selected"
@endif
>
{!! __('label.deactivated') !!}
</option>
</select>
<div class="clearfix"></div>
<p class="stdformbutton">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="save" />
</p>
</div>
<div class="col-md-6">
<h4 class="widgettitle title-light">{!! __('label.project_access') !!}</h4>
<div class="scrollableItemList">
@php
$currentClient = '';
$i = 0;
$containerOpen = false;
@endphp
@foreach ($allProjects as $row)
@if ($currentClient != $row['clientName'])
@if ($i > 0 && $containerOpen)
</div>
@php $containerOpen = false; @endphp
@endif
<h3 id="accordion_link_{{ $i }}">
<a href="#" onclick="accordionToggle({{ $i }});" id="accordion_toggle_{{ $i }}"><i class="fa fa-angle-down"></i> {{ $tpl->escape($row['clientName']) }}</a>
</h3>
<div id="accordion_{{ $i }}" class="simpleAccordionContainer">
@php
$currentClient = $row['clientName'];
$containerOpen = true;
@endphp
@endif
<div class="item">
<input type="checkbox" name="projects[]" id="project_{{ $row['id'] }}" value="{{ $row['id'] }}"
@if (is_array($relations) === true && in_array($row['id'], $relations) === true)
checked="checked"
@endif
/><label for="project_{{ $row['id'] }}">{{ $tpl->escape($row['name']) }}</label>
<div class="clearall"></div>
</div>
@php $i++; @endphp
@endforeach
@if ($containerOpen)
</div>
@endif
</div>
</div>
</div>
</form>
</div>
@once
@push('scripts')
<script>
jQuery(".noClickProp.dropdown-menu").on("click", function(e) {
e.stopPropagation();
});
function accordionToggle(id) {
let currentLink = jQuery("#accordion_toggle_"+id).find("i.fa");
if (currentLink.hasClass("fa-angle-right")){
currentLink.removeClass("fa-angle-right");
currentLink.addClass("fa-angle-down");
jQuery('#accordion_'+id).slideDown("fast");
} else {
currentLink.removeClass("fa-angle-down");
currentLink.addClass("fa-angle-right");
jQuery('#accordion_'+id).slideUp("fast");
}
}
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,30 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pageicon"><i class="fa-solid fa-key"></i></div>
<div class="pagetitle">
<h5>{!! __('label.administration') !!}</h5>
<h1>{!! __('headlines.delete_key') !!}</h1>
</div>
</div><!--pageheader-->
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<h5 class="subtitle">{!! __('subtitles.delete_key') !!}</h5>
<form method="post">
<input type="hidden" name="{{ session('formTokenName') }}" value="{{ session('formTokenValue') }}" />
<p>{!! __('text.confirm_key_deletion') !!}</p><br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" link="{{ BASE_URL }}/setting/editCompanySettings/#apiKeys" contentRole="tertiary">{!! __('buttons.back') !!}</x-global::forms.button>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,147 @@
@extends($layout)
@section('content')
@php
$apiKeyValues = $apiKeyValues ?? false;
@endphp
<div style="min-width:700px;">
<h4 class="widgettitle title-light"><i class="fa fa-key"></i> {!! __('headlines.new_api_key') !!}</h4>
{!! $tpl->displayNotification() !!}
@if ($apiKeyValues !== false && isset($apiKeyValues['id']))
<p>Your API Key was successfully created. Please copy the key below. This is your only chance to copy it.</p>
<x-global::forms.text-input id="apiKey" value="lt_{{ $apiKeyValues['user'] }}_{{ $apiKeyValues['passwordClean'] }}" style="width:100%;" />
<x-global::forms.button contentRole="primary" onclick="leantime.snippets.copyUrl('apiKey');">{!! __('links.copy_key') !!}</x-global::forms.button>
@else
<form action="{{ BASE_URL }}/api/newApiKey" method="post" class="stdform formModal" >
<input type="hidden" name="save" value="1" />
<div class="row" >
<div class="col-md-6">
<h4 class="widgettitle title-light">{!! __('label.basic_information') !!}</h4>
<label for="firstname">{!! __('label.key_name') !!}</label><div class="clearfix"></div>
<x-global::forms.text-input
name="firstname" id="firstname"
value="" /><br />
<label for="role">{!! __('label.role') !!}</label><div class="clearfix"></div>
<select name="role" id="role">
@foreach ($roles as $key => $role)
<option value="{{ $key }}"
@if ($key == $values['role'])
selected="selected"
@endif
>
{!! __('label.roles.' . $role) !!}
</option>
@endforeach
</select> <br />
<label for="status">{!! __('label.status') !!}</label><div class="clearfix"></div>
<select name="status" id="status">
<option value="a"
@if (strtolower($values['status']) == 'a')
selected="selected"
@endif
>
{!! __('label.active') !!}
</option>
<option value=""
@if (strtolower($values['status']) == '')
selected="selected"
@endif
>
{!! __('label.deactivated') !!}
</option>
</select>
<div class="clearfix"></div>
<p class="stdformbutton">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="save" />
</p>
</div>
<div class="col-md-6">
<h4 class="widgettitle title-light">{!! __('label.project_access') !!}</h4>
<div class="scrollableItemList">
@php
$currentClient = '';
$i = 0;
$containerOpen = false;
@endphp
@foreach ($allProjects as $row)
@if ($currentClient != $row['clientName'])
@if ($i > 0 && $containerOpen)
</div>
@php $containerOpen = false; @endphp
@endif
<h3 id="accordion_link_{{ $i }}">
<a href="#" onclick="accordionToggle({{ $i }});" id="accordion_toggle_{{ $i }}"><i class="fa fa-angle-down"></i> {{ $tpl->escape($row['clientName']) }}</a>
</h3>
<div id="accordion_{{ $i }}" class="simpleAccordionContainer">
@php
$currentClient = $row['clientName'];
$containerOpen = true;
@endphp
@endif
<div class="item">
<input type="checkbox" name="projects[]" id="project_{{ $row['id'] }}" value="{{ $row['id'] }}"
@if (is_array($relations) === true && in_array($row['id'], $relations) === true)
checked="checked"
@endif
/><label for="project_{{ $row['id'] }}">{{ $tpl->escape($row['name']) }}</label>
<div class="clearall"></div>
</div>
@php $i++; @endphp
@endforeach
@if ($containerOpen)
</div>
@endif
</div>
</div>
</div>
@endif
</form>
</div>
@once
@push('scripts')
<script>
jQuery(".noClickProp.dropdown-menu").on("click", function(e) {
e.stopPropagation();
});
function accordionToggle(id) {
let currentLink = jQuery("#accordion_toggle_"+id).find("i.fa");
if (currentLink.hasClass("fa-angle-right")){
currentLink.removeClass("fa-angle-right");
currentLink.addClass("fa-angle-down");
jQuery('#accordion_'+id).slideDown("fast");
} else {
currentLink.removeClass("fa-angle-down");
currentLink.addClass("fa-angle-right");
jQuery('#accordion_'+id).slideUp("fast");
}
}
</script>
@endpush
@endonce
@endsection