OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
407
app/Domain/Comments/Services/Comments.php
Normal file
407
app/Domain/Comments/Services/Comments.php
Normal file
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Comments\Services;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Comments\Permissions\CommentsPermissions;
|
||||
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
|
||||
use Leantime\Domain\Notifications\Models\Notification;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reactions\Services\Reactions as ReactionsService;
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
class Comments extends BaseService
|
||||
{
|
||||
private CommentRepository $commentRepository;
|
||||
|
||||
private ProjectService $projectService;
|
||||
|
||||
private LanguageCore $language;
|
||||
|
||||
private ReactionsService $reactionsService;
|
||||
|
||||
public function __construct(
|
||||
CommentRepository $commentRepository,
|
||||
ProjectService $projectService,
|
||||
LanguageCore $language,
|
||||
ReactionsService $reactionsService
|
||||
) {
|
||||
$this->commentRepository = $commentRepository;
|
||||
$this->projectService = $projectService;
|
||||
$this->language = $language;
|
||||
$this->reactionsService = $reactionsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the entity object backing a comment when the caller didn't
|
||||
* pass one. Web controllers usually already have the entity loaded
|
||||
* before invoking addComment(); RPC callers don't, and shouldn't
|
||||
* have to pre-fetch the entire ticket just to leave a comment.
|
||||
*/
|
||||
private function loadEntityForComment(string $module, int $entityId)
|
||||
{
|
||||
try {
|
||||
if ($module === 'ticket') {
|
||||
$ticketService = app()->make(\Leantime\Domain\Tickets\Services\Tickets::class);
|
||||
$ticket = $ticketService->getTicket($entityId);
|
||||
|
||||
return $ticket ?: null;
|
||||
}
|
||||
if ($module === 'project') {
|
||||
$projectService = app()->make(\Leantime\Domain\Projects\Services\Projects::class);
|
||||
|
||||
return $projectService->getProject($entityId) ?: null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CommentsPermissions::VIEW, entityScoped: true)]
|
||||
public function getComments($module, $entityId, int $commentOrder = 0, int $parent = 0): false|array
|
||||
{
|
||||
// IDOR fence: comments are read by (module, entityId) with no project scoping in the repo,
|
||||
// so authorize VIEW against the host entity's REAL project — a foreign id can no longer leak
|
||||
// another project's comment thread over RPC. A null project (client/company-scoped target or
|
||||
// an unknown module) falls back to a session-scoped capability check (unchanged behavior).
|
||||
$projectId = $this->commentRepository->resolveModuleProjectId((string) $module, (int) $entityId);
|
||||
$this->authorize(CommentsPermissions::VIEW, $projectId);
|
||||
|
||||
return $this->commentRepository->getComments($module, $entityId, $parent, $commentOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CommentsPermissions::CREATE, entityScoped: true)]
|
||||
public function addComment($values, $module, $entityId, $entity = null): bool
|
||||
{
|
||||
// RPC callers (mobile) typically don't pre-load the entity — they
|
||||
// just know module + entityId. Load it server-side so they don't
|
||||
// have to ship a whole ticket payload over the wire just to comment.
|
||||
if ($entity === null && $module && $entityId) {
|
||||
$entity = $this->loadEntityForComment($module, (int) $entityId);
|
||||
}
|
||||
|
||||
// Commenting is a commenter+ capability. Resolve the host entity's project so the
|
||||
// check is scoped to it (ticket -> projectId; project -> its own id), then authorize.
|
||||
$projectId = is_object($entity) && isset($entity->projectId)
|
||||
? (int) $entity->projectId
|
||||
: ($module === 'project' ? (int) $entityId : null);
|
||||
|
||||
// Fall back to resolving the host entity's project by (module, id) so canvas-family and
|
||||
// other targets (whose $entity may be an array or unloaded) are also project-fenced.
|
||||
if ($projectId === null) {
|
||||
$projectId = $this->commentRepository->resolveModuleProjectId((string) $module, (int) $entityId);
|
||||
}
|
||||
|
||||
$this->authorize(CommentsPermissions::CREATE, $projectId);
|
||||
|
||||
// Default father (parent comment id) to 0 if not provided. The
|
||||
// original code REQUIRED it via isset(), which forced every caller
|
||||
// to send a value even when there was no parent. 0 is the sentinel
|
||||
// for "top-level comment, no parent."
|
||||
if (! isset($values['father'])) {
|
||||
$values['father'] = $values['parentId'] ?? 0;
|
||||
}
|
||||
|
||||
if (isset($values['text']) && $values['text'] != '' && isset($values['father']) && isset($module) && isset($entityId) && isset($entity)) {
|
||||
$mapper = [
|
||||
'text' => $values['text'],
|
||||
'date' => dtHelper()->dbNow()->formatDateTimeForDb(),
|
||||
'userId' => (session('userdata.id')),
|
||||
'moduleId' => $entityId,
|
||||
'commentParent' => ($values['father']),
|
||||
'status' => $values['status'] ?? '',
|
||||
];
|
||||
|
||||
$comment = $this->commentRepository->addComment($mapper, $module);
|
||||
|
||||
if ($comment) {
|
||||
$mapper['id'] = $comment;
|
||||
|
||||
$currentUrl = CURRENT_URL;
|
||||
|
||||
switch ($module) {
|
||||
case 'ticket':
|
||||
$subject = sprintf($this->language->__('email_notifications.new_comment_todo_with_type_subject'), $this->language->__('label.'.strtolower($entity->type)), $entity->id, strip_tags($entity->headline));
|
||||
$message = sprintf($this->language->__('email_notifications.new_comment_todo_with_type_message'), session('userdata.name'), $this->language->__('label.'.strtolower($entity->type)), strip_tags($entity->headline), strip_tags($values['text']));
|
||||
$linkLabel = $this->language->__('email_notifications.new_comment_todo_cta');
|
||||
$currentUrl = BASE_URL.'#/tickets/showTicket/'.$entity->id;
|
||||
break;
|
||||
case 'project':
|
||||
$subject = sprintf($this->language->__('email_notifications.new_comment_project_subject'), $entityId, strip_tags($entity['name']));
|
||||
$message = sprintf($this->language->__('email_notifications.new_comment_project_message'), session('userdata.name'), strip_tags($entity['name']));
|
||||
$linkLabel = $this->language->__('email_notifications.new_comment_project_cta');
|
||||
break;
|
||||
default:
|
||||
$subject = $this->language->__('email_notifications.new_comment_general_subject');
|
||||
$message = sprintf($this->language->__('email_notifications.new_comment_general_message'), session('userdata.name'));
|
||||
$linkLabel = $this->language->__('email_notifications.new_comment_general_cta');
|
||||
break;
|
||||
}
|
||||
|
||||
$notification = app()->make(Notification::class);
|
||||
|
||||
$urlQueryParameter = str_contains($currentUrl, '?') ? '&' : '?';
|
||||
$notification->url = [
|
||||
'url' => $currentUrl.$urlQueryParameter.'projectId='.session('currentProject'),
|
||||
'text' => $linkLabel,
|
||||
];
|
||||
|
||||
$notification->entity = $mapper;
|
||||
$notification->module = 'comments';
|
||||
$notification->action = 'commented';
|
||||
// session('currentProject') is set when a user is browsing
|
||||
// a project on web; RPC callers (mobile) don't have that
|
||||
// session key populated, and the Notification model types
|
||||
// projectId as `int` (rejects null). Fall back to the
|
||||
// commented-on entity's project so we always have a real
|
||||
// integer.
|
||||
$entityProjectId = is_object($entity)
|
||||
? ($entity->projectId ?? 0)
|
||||
: (is_array($entity) ? ($entity['projectId'] ?? $entity['id'] ?? 0) : 0);
|
||||
$notification->projectId = (int) (session('currentProject') ?? $entityProjectId);
|
||||
$notification->subject = $subject;
|
||||
$notification->authorId = session('userdata.id');
|
||||
$notification->message = $message;
|
||||
|
||||
$this->projectService->notifyProjectUsers($notification);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current user is authorized to modify a comment.
|
||||
* The caller must be the comment author or have at least manager role.
|
||||
*
|
||||
* @param int $commentId The comment ID to check
|
||||
* @return bool True if authorized, false otherwise
|
||||
*/
|
||||
private function canModifyComment(int $commentId): bool
|
||||
{
|
||||
$comment = $this->commentRepository->getComment($commentId);
|
||||
|
||||
if (! $comment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentUserId = session('userdata.id');
|
||||
|
||||
// Comment author can always modify their own comment.
|
||||
if ((int) $comment['userId'] === (int) $currentUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise moderation (editing/deleting someone else's comment) is a manager+ capability,
|
||||
// scoped to the comment's OWN project so a manager in project A cannot moderate a comment on
|
||||
// an entity in project B by id. A null project (client/company-scoped or unknown module)
|
||||
// falls back to a session-scoped moderate check (unchanged behavior for those targets).
|
||||
$projectId = $this->commentRepository->resolveModuleProjectId(
|
||||
(string) ($comment['module'] ?? ''),
|
||||
(int) ($comment['moduleId'] ?? 0)
|
||||
);
|
||||
|
||||
return $this->can(CommentsPermissions::MODERATE, $projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a comment. The caller must be the comment author or a manager+.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CommentsPermissions::CREATE, entityScoped: true)]
|
||||
public function editComment($values, $id): bool
|
||||
{
|
||||
if (! $this->canModifyComment((int) $id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->commentRepository->editComment($values['text'], $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment. The caller must be the comment author or a manager+.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CommentsPermissions::CREATE, entityScoped: true)]
|
||||
public function deleteComment($commentId): bool
|
||||
{
|
||||
if (! $this->canModifyComment((int) $commentId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->commentRepository->deleteComment($commentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?int $projectId Project ID
|
||||
* @param ?int $moduleId Id of the entity to pull comments from
|
||||
* @return array
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CommentsPermissions::VIEW, projectIdParam: 'projectId')]
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array|false
|
||||
{
|
||||
|
||||
$comments = $this->commentRepository->getAllAccountComments($projectId, $moduleId);
|
||||
|
||||
foreach ($comments as $key => $comment) {
|
||||
if (dtHelper()->isValidDateString($comment['date'])) {
|
||||
$comments[$key]['date'] = dtHelper()->parseDbDateTime($comment['date'])->toIso8601ZuluString();
|
||||
} else {
|
||||
$comments[$key]['date'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a sentiment reaction on a comment for a given user.
|
||||
*
|
||||
* Enforces the domain rule that a user may only have one sentiment
|
||||
* reaction per comment: clicking the reaction the user already has
|
||||
* removes it (toggle off); clicking a different reaction removes any
|
||||
* existing reactions first, then adds the new one. Unknown reaction
|
||||
* types are rejected.
|
||||
*
|
||||
* @param int $userId Ignored — reactions always act as the session user (kept for RPC
|
||||
* signature compatibility). See the in-body session pin.
|
||||
* @param int $commentId The comment being reacted to
|
||||
* @param string $reaction The reaction code (e.g. an emoji key)
|
||||
* @return bool True when the toggle was applied, false when the reaction
|
||||
* type is unknown and nothing was changed
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CommentsPermissions::CREATE, entityScoped: true)]
|
||||
public function toggleCommentReaction(int $userId, int $commentId, string $reaction): bool
|
||||
{
|
||||
// Reactions act on behalf of the SESSION user only. Ignore any caller-supplied id so a
|
||||
// client cannot toggle reactions as another user (the $userId param is kept for RPC
|
||||
// signature compatibility but is not trusted).
|
||||
$userId = (int) session('userdata.id');
|
||||
|
||||
// Validate reaction against known types
|
||||
if ($this->reactionsService->getReactionType($reaction) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IDOR fence: gate against the comment's OWN project (null -> session-scoped fallback), so
|
||||
// reactions can't be toggled on another project's comment by id. SOFT-deny (same false
|
||||
// return as a missing comment) rather than throw, so a denied cross-project comment is
|
||||
// indistinguishable from a non-existent one — no commentId existence oracle.
|
||||
$comment = $this->commentRepository->getComment($commentId);
|
||||
if (! $comment) {
|
||||
return false;
|
||||
}
|
||||
if (! $this->can(
|
||||
CommentsPermissions::CREATE,
|
||||
$this->commentRepository->resolveModuleProjectId(
|
||||
(string) ($comment['module'] ?? ''),
|
||||
(int) ($comment['moduleId'] ?? 0)
|
||||
)
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user already has this exact reaction
|
||||
$existingSameReaction = $this->reactionsService->getUserReactions($userId, 'comment', $commentId, $reaction);
|
||||
|
||||
if (! empty($existingSameReaction)) {
|
||||
// User clicked the same reaction - remove it (toggle off)
|
||||
$this->reactionsService->removeReaction($userId, 'comment', $commentId, $reaction);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// User wants to add a reaction - first remove any existing reactions
|
||||
// (only one sentiment reaction allowed per user per comment)
|
||||
$allUserReactions = $this->reactionsService->getUserReactions($userId, 'comment', $commentId);
|
||||
if (is_array($allUserReactions)) {
|
||||
foreach ($allUserReactions as $existingReaction) {
|
||||
$this->reactionsService->removeReaction($userId, 'comment', $commentId, $existingReaction['reaction']);
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the new reaction
|
||||
$this->reactionsService->addReaction($userId, 'comment', $commentId, $reaction);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the reaction view data for a comment.
|
||||
*
|
||||
* Returns the grouped reactions (with user names for tooltips) plus a
|
||||
* flat list of the given user's reaction codes for the comment, ready
|
||||
* to be assigned to the template.
|
||||
*
|
||||
* @param int $commentId The comment to load reactions for
|
||||
* @param int $userId The current user id (0 when anonymous)
|
||||
* @return array{reactions: array, userReactions: list<string>} View data
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CommentsPermissions::VIEW, entityScoped: true)]
|
||||
public function getCommentReactions(int $commentId, int $userId): array
|
||||
{
|
||||
// IDOR fence: gate VIEW against the comment's OWN project before exposing reactor
|
||||
// identities/sentiment, closing the cross-project reaction-read leak by comment id (RPC +
|
||||
// Hx). SOFT-deny (same empty payload as a missing comment) rather than throw, so a denied
|
||||
// cross-project comment is indistinguishable from a non-existent one — no existence oracle.
|
||||
$comment = $this->commentRepository->getComment($commentId);
|
||||
if (! $comment) {
|
||||
return ['reactions' => [], 'userReactions' => []];
|
||||
}
|
||||
if (! $this->can(
|
||||
CommentsPermissions::VIEW,
|
||||
$this->commentRepository->resolveModuleProjectId(
|
||||
(string) ($comment['module'] ?? ''),
|
||||
(int) ($comment['moduleId'] ?? 0)
|
||||
)
|
||||
)) {
|
||||
return ['reactions' => [], 'userReactions' => []];
|
||||
}
|
||||
|
||||
// Get reactions with user names for tooltips
|
||||
$reactionsWithUsers = $this->reactionsService->getEntityReactionsWithUsers('comment', $commentId);
|
||||
|
||||
// Flatten the user's reactions for this comment into a list of codes
|
||||
$userReactionsList = [];
|
||||
if ($userId) {
|
||||
$userReactionsData = $this->reactionsService->getUserReactions($userId, 'comment', $commentId);
|
||||
if (is_array($userReactionsData)) {
|
||||
foreach ($userReactionsData as $r) {
|
||||
$userReactionsList[] = $r['reaction'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'reactions' => $reactionsWithUsers ?: [],
|
||||
'userReactions' => $userReactionsList,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user