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