OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,541 @@
<?php
namespace Leantime\Domain\Notifications\Services;
use Exception;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\OutboundUrlGuard;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Tickets\Services\Tickets;
class Messengers
{
private Client $httpClient;
private SettingRepository $settingsRepo;
private LanguageCore $language;
private array $supportedMessengers = ['slack', 'discord', 'mattermost', 'zulip', 'telegram'];
private string $projectName = '';
/**
* __construct - get database connection
*
*
* @api
*/
public function __construct(
Client $httpClient,
SettingRepository $settingsRepo,
LanguageCore $language
) {
$this->httpClient = $httpClient;
$this->settingsRepo = $settingsRepo;
$this->language = $language;
}
/**
* @api
*/
/**
* @api
*/
public function sendNotificationToMessengers(NotificationModel $notification, $projectName, array|string $messengers = 'all'): void
{
$this->projectName = $projectName ?? 'a Leantime project';
$messengersToSend = [];
if (is_string($messengers) && $messengers == 'all') {
$messengersToSend = $this->supportedMessengers;
} elseif (is_array($messengers)) {
foreach ($messengers as $messenger) {
if (in_array($messenger, $this->supportedMessengers)) {
$messengersToSend[] = $messenger;
}
}
}
foreach ($messengersToSend as $messenger) {
$this->{''.$messenger.'Webhook'}($notification);
}
}
/**
* slackWebhook
*
*
* @api
*/
private function slackWebhook(NotificationModel $notification): bool
{
$slackWebhookURL = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.slackWebhookURL");
if ($slackWebhookURL !== '' && $slackWebhookURL !== false) {
$message = $this->prepareMessage($notification);
$data = [
'text' => '',
'attachments' => $message,
];
$data_string = json_encode($data);
try {
if (! OutboundUrlGuard::isAllowedUrl($slackWebhookURL)) {
Log::warning('Blocked Slack webhook to disallowed URL (SSRF guard)', ['host' => parse_url($slackWebhookURL, PHP_URL_HOST)]);
return false;
}
$this->httpClient->post($slackWebhookURL, [
'allow_redirects' => OutboundUrlGuard::redirectOptions(),
'body' => $data_string,
'headers' => ['Content-Type' => 'application/json'],
]);
return true;
} catch (\Throwable $e) {
report($e);
return false;
}
}
return false;
}
/**
* mattermostWebhook
*
*
* @api
*/
private function mattermostWebhook(NotificationModel $notification): bool
{
$mattermostWebhookURL = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.mattermostWebhookURL");
if ($mattermostWebhookURL !== '' && $mattermostWebhookURL !== false) {
$message = $this->prepareMessage($notification);
$data = [
'username' => 'Leantime',
'icon_url' => '',
'text' => '',
'attachments' => $message,
];
$data_string = json_encode($data);
try {
if (! OutboundUrlGuard::isAllowedUrl($mattermostWebhookURL)) {
Log::warning('Blocked Mattermost webhook to disallowed URL (SSRF guard)', ['host' => parse_url($mattermostWebhookURL, PHP_URL_HOST)]);
return false;
}
$this->httpClient->post($mattermostWebhookURL, [
'allow_redirects' => OutboundUrlGuard::redirectOptions(),
'body' => $data_string,
]);
return true;
} catch (Exception $e) {
report($e);
return false;
}
}
return false;
}
/**
* zulipWebhook
*
*
* @api
*/
private function zulipWebhook(NotificationModel $notification): bool
{
$zulipWebhookSerialized = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.zulipHook");
if ($zulipWebhookSerialized !== false && $zulipWebhookSerialized !== '') {
$zulipWebhook = safe_unserialize($zulipWebhookSerialized, []);
$botEmail = $zulipWebhook['zulipEmail'];
$botKey = $zulipWebhook['zulipBotKey'];
$botURL = $zulipWebhook['zulipURL'].'/api/v1/messages';
$prepareChatMessage = '**Project: '.$this->projectName."** \n\r".$notification->message;
if ($notification->url !== false) {
$prepareChatMessage .= ' '.$notification->url['url'].'';
}
$data = [
'type' => 'stream',
'to' => $zulipWebhook['zulipStream'],
'topic' => $zulipWebhook['zulipTopic'],
'content' => $prepareChatMessage,
];
$curlUrl = $botURL.'?'.http_build_query($data);
$data_string = json_encode($data);
try {
if (! OutboundUrlGuard::isAllowedUrl($curlUrl)) {
Log::warning('Blocked Zulip webhook to disallowed URL (SSRF guard)', ['host' => parse_url($curlUrl, PHP_URL_HOST)]);
return false;
}
$this->httpClient->post($curlUrl, [
'allow_redirects' => OutboundUrlGuard::redirectOptions(),
'body' => $data_string,
'headers' => ['Content-Type' => 'application/json'],
'auth' => [
$botEmail,
$botKey,
],
]);
return true;
} catch (\Throwable $e) {
report($e);
return false;
}
}
return false;
}
/**
* telegramWebhook
*/
private function telegramWebhook(NotificationModel $notification): bool
{
$telegramHookSerialized = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.telegramHook");
if ($telegramHookSerialized !== false && $telegramHookSerialized !== '') {
$telegramHook = safe_unserialize($telegramHookSerialized, []);
if (! is_array($telegramHook) || empty($telegramHook['telegramBotToken']) || empty($telegramHook['telegramChatId'])) {
return false;
}
$text = $this->prepareTelegramMessage($notification);
$data = [
'chat_id' => $telegramHook['telegramChatId'],
'text' => $text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
];
if (! empty($telegramHook['telegramTopicId']) && is_numeric($telegramHook['telegramTopicId']) && (int) $telegramHook['telegramTopicId'] > 0) {
$data['message_thread_id'] = (int) $telegramHook['telegramTopicId'];
}
try {
$response = $this->httpClient->post(
"https://api.telegram.org/bot{$telegramHook['telegramBotToken']}/sendMessage",
[
'allow_redirects' => OutboundUrlGuard::redirectOptions(),
'connect_timeout' => 5,
'timeout' => 10,
'json' => $data,
]
);
$resBody = json_decode((string) $response->getBody(), true);
return is_array($resBody) && ! empty($resBody['ok']);
} catch (\Throwable $e) {
Log::warning('Telegram sendMessage failed', ['exception' => get_class($e)]);
return false;
}
}
return false;
}
/**
* prepareTelegramMessage
*/
private function prepareTelegramMessage(NotificationModel $notification): string
{
$headline = '';
$status = '';
$priority = '';
$userId = 0;
$userFirstname = '';
$userLastname = '';
$dateToFinish = '';
if (isset($notification->entity)) {
if (is_array($notification->entity)) {
$headline = $notification->entity['headline'] ?? '';
$status = $notification->entity['status'] ?? '';
$priority = $notification->entity['priority'] ?? '';
$userId = (int) ($notification->entity['userId'] ?? 0);
$userFirstname = $notification->entity['userFirstname'] ?? $notification->entity['user_firstname'] ?? '';
$userLastname = $notification->entity['userLastname'] ?? $notification->entity['user_lastname'] ?? '';
$dateToFinish = $notification->entity['dateToFinish'] ?? $notification->entity['timelineDateToFinish'] ?? '';
} elseif (is_object($notification->entity)) {
$headline = $notification->entity->headline ?? '';
$status = $notification->entity->status ?? '';
$priority = $notification->entity->priority ?? '';
$userId = (int) ($notification->entity->userId ?? 0);
$userFirstname = $notification->entity->userFirstname ?? $notification->entity->user_firstname ?? '';
$userLastname = $notification->entity->userLastname ?? $notification->entity->user_lastname ?? '';
$dateToFinish = $notification->entity->dateToFinish ?? $notification->entity->timelineDateToFinish ?? '';
}
}
$ticketService = null;
try {
$ticketService = app()->make(Tickets::class);
} catch (\Throwable $e) {
// Container resolution fallback
}
// 1. Task Title
$taskTitle = ! empty($headline) ? $headline : $notification->message;
// 2. Status
$statusName = '';
if (! empty($status)) {
if ($ticketService !== null) {
try {
$statusLabelsArray = $ticketService->getStatusLabels($notification->projectId);
if (! empty($statusLabelsArray[$status]['name'])) {
$statusName = $statusLabelsArray[$status]['name'];
} else {
$statusName = (string) $status;
}
} catch (\Throwable $e) {
$statusName = (string) $status;
}
} else {
$statusName = (string) $status;
}
}
// 3. Priority
$priorityName = '';
if (! empty($priority)) {
if ($ticketService !== null) {
try {
$priorityLabels = $ticketService->getPriorityLabels();
if (! empty($priorityLabels[$priority])) {
$priorityName = $priorityLabels[$priority];
}
} catch (\Throwable $e) {
// Fallback to static mapping
}
}
if (empty($priorityName)) {
$priorityMap = [
'1' => 'Critical',
'2' => 'High',
'3' => 'Medium',
'4' => 'Low',
'5' => 'Lowest',
'critical' => 'Critical',
'high' => 'High',
'medium' => 'Medium',
'low' => 'Low',
'lowest' => 'Lowest',
'urgent' => 'Urgent',
];
$priorityName = $priorityMap[strtolower((string) $priority)] ?? (string) $priority;
}
}
// 4. Assigned To
$assignedTo = trim("{$userFirstname} {$userLastname}");
if (empty($assignedTo) && $userId > 0) {
try {
$userService = app()->make(\Leantime\Domain\Users\Services\Users::class);
$user = $userService->getUser($userId);
if (! empty($user)) {
$assignedTo = trim(($user['firstname'] ?? '').' '.($user['lastname'] ?? ''));
}
} catch (\Throwable $e) {
// Keep default if user service unresolvable
}
}
// 5. Due Date
$formattedDueDate = '';
if (! empty($dateToFinish) && $dateToFinish !== '0000-00-00 00:00:00' && $dateToFinish !== '0000-00-00') {
try {
$formattedDueDate = dtHelper()->parseDbDateTime($dateToFinish)->formatDateForUser();
} catch (\Throwable $e) {
$formattedDueDate = (string) $dateToFinish;
}
}
// 6. Link
$urlLink = is_array($notification->url) && ! empty($notification->url['url']) ? $notification->url['url'] : '';
// Build clean Telegram message
$lines = [];
$lines[] = '📋 <b>'.e($this->projectName).'</b>';
$lines[] = '';
if (! empty($taskTitle)) {
$lines[] = '📌 <b>'.e($this->language->__('label.title')).':</b> '.e($taskTitle);
}
if (! empty($statusName) && $statusName !== 'N/A') {
$lines[] = '🏷 <b>'.e($this->language->__('label.todo_status')).':</b> '.e($statusName);
}
if (! empty($priorityName)) {
$lines[] = '⚡ <b>'.e($this->language->__('label.priority')).':</b> '.e($priorityName);
}
if (! empty($assignedTo) && $assignedTo !== 'Unassigned') {
$lines[] = '👤 <b>'.e($this->language->__('label.assigned_to')).':</b> '.e($assignedTo);
}
if (! empty($formattedDueDate)) {
$lines[] = '📅 <b>'.e($this->language->__('label.due_date')).':</b> '.e($formattedDueDate);
}
if (! empty($urlLink)) {
$hrefUrl = $urlLink;
// Only rewrite localhost→127.0.0.1 in local/dev environments.
// Self-hosted installs that legitimately use localhost as their base URL
// must not be mutated in production.
if (app()->isLocal()) {
$hrefUrl = preg_replace('/^(https?:\/\/)localhost(?=[\/:]|$)/i', '${1}127.0.0.1', $hrefUrl);
}
$hrefUrl = str_replace('#', '%23', $hrefUrl);
$lines[] = '';
$lines[] = '👉 <a href="'.e($hrefUrl).'">'.e($this->language->__('label.open_in_leantime')).'</a>';
}
return implode("\n", $lines);
}
/**
* mattermostWebhook
*
*
* @api
*/
public function discordWebhook(NotificationModel $notification): bool
{
$ticketService = app()->make(Tickets::class);
for ($i = 1; $i <= 3; $i++) {
$discordWebhookURL = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.discordWebhookURL{$i}");
if ($discordWebhookURL !== '' && $discordWebhookURL !== false) {
$fields = [
[
'name' => $this->language->__('label.project'),
'value' => $this->projectName,
'inline' => true,
],
];
$statusLabelsArray = $ticketService->getStatusLabels($notification->projectId);
if (! empty($notification->entity->status) && ! empty($statusLabelsArray[$notification->entity->status])) {
$fields[] = [
'name' => $this->language->__('label.todo_status'),
'value' => $statusLabelsArray[$notification->entity->status]['name'],
'inline' => true,
];
}
$url_link = (
empty($notification->url['url'])
? ''
: $notification->url['url']
);
// For details on the JSON layout: https://birdie0.github.io/discord-webhooks-guide/index.html
$data_string = json_encode([
'avatar_url' => 'https://s3-us-west-2.amazonaws.com/leantime-website/wp-content/uploads/2019/03/22224016/logoIcon.png',
'tts' => false,
'embeds' => [
[
'color' => hexdec('1b75bb'),
'title' => $notification->message,
'url' => $url_link,
'timestamp' => date('c', strtotime('now')),
'fields' => $fields,
],
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
try {
if (! OutboundUrlGuard::isAllowedUrl($discordWebhookURL)) {
Log::warning('Blocked Discord webhook to disallowed URL (SSRF guard)', ['host' => parse_url($discordWebhookURL, PHP_URL_HOST)]);
return false;
}
$this->httpClient->post($discordWebhookURL, [
'allow_redirects' => OutboundUrlGuard::redirectOptions(),
'body' => $data_string,
'headers' => ['Content-Type' => 'application/json'],
]);
} catch (\Throwable $e) {
report($e);
return false;
}
}
}
return true;
}
/**
* @return array[]
*
* @api
*/
public function prepareMessage(NotificationModel $notification): array
{
$ticketService = app()->make(Tickets::class);
if (is_array($notification->entity)) {
$headline = $notification->entity['headline'] ?? '';
$status = $notification->entity['status'] ?? '';
} else {
$headline = $notification->entity->headline;
$status = $notification->entity->status;
}
$fields = [
'title' => $this->language->__('headlines.project_with_name').' '.$this->projectName,
'short' => false,
];
$statusLabelsArray = $ticketService->getStatusLabels($notification->projectId);
if (! empty($statusLabelsArray[$status])) {
$fields['value'] = $this->language->__('label.todo_status').': '.$statusLabelsArray[$status]['name'];
}
$message = [
[
'color' => '#006d9f',
'fallback' => $notification->message,
'pretext' => $notification->message,
'title' => $headline,
'title_link' => $notification->url['url'],
'fields' => $fields,
],
];
return $message;
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace Leantime\Domain\Notifications\Services;
use Illuminate\Support\Facades\Log;
use Leantime\Domain\Setting\Services\Setting;
/**
* @api
*/
class News
{
private Setting $settingService;
/**
* __construct - get database connection
*
*
* @api
*/
public function __construct(
Setting $settingService
) {
$this->settingService = $settingService;
}
public function getLatest(int $userId): false|\SimpleXMLElement
{
if (! env('LEAN_NEWS_ENABLED', true)) {
return false;
}
try {
$rss = $this->getFeed();
} catch (\Exception $e) {
Log::warning('Could not connect to news server.');
Log::warning($e);
return false;
}
$latestGuid = (string) $rss->channel->item[0]->guid;
$this->settingService->saveSetting('usersettings.'.$userId.'.lastNewsGuid', strval($latestGuid));
// Todo: check last article the user read
// Only load rss feed once a day
return $rss;
}
public function hasNews(int $userId): bool
{
if (! env('LEAN_NEWS_ENABLED', true)) {
return false;
}
try {
$rss = $this->getFeed();
} catch (\Exception $e) {
Log::warning('Could not connect to news server.');
Log::warning($e);
return false;
}
$latestGuid = (string) $rss->channel->item[0]->guid;
$lastNewsGuid = $this->settingService->getSetting('usersettings.'.$userId.'.lastNewsGuid');
if ($lastNewsGuid === false) {
return true;
}
if ($lastNewsGuid !== $latestGuid) {
return true;
}
return false;
}
/**
* getFeed - Fetches the feed from a remote URL and returns the contents as a SimpleXMLElement object
*
* @return \SimpleXMLElement - The parsed XML content as a SimpleXMLElement object
*
* @throws \Exception - If the simplexml_load_string function doesn't exist
*
* @api
*/
public function getFeed()
{
$client = new \GuzzleHttp\Client;
$response = $client->request('GET', 'https://leantime.io/category/leantime-updates/feature-updates/feed/', [
'headers' => ['Accept' => 'application/xml'],
// Fail fast when the server (or CI runner) has no egress so the news
// badge/widget degrades quickly instead of stalling. (#3372/#3373)
'connect_timeout' => 2,
'timeout' => 5,
])->getBody()->getContents();
if (function_exists('simplexml_load_string')) {
$responseXml = simplexml_load_string($response);
} else {
throw new \Exception('Simple XML extension is not installed');
}
return $responseXml;
}
}

View File

@@ -0,0 +1,406 @@
<?php
namespace Leantime\Domain\Notifications\Services;
use DOMDocument;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Mailer as MailerCore;
use Leantime\Core\Support\NameSanitizer;
use Leantime\Domain\Notifications\Repositories\Notifications as NotificationRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
/**
* @api
*/
class Notifications
{
private NotificationRepository $notificationsRepo;
private UserRepository $userRepository;
private LanguageCore $language;
/**
* __construct - get database connection
*
*
* @api
*/
public function __construct(
NotificationRepository $notificationsRepo,
UserRepository $userRepository,
LanguageCore $language
) {
$this->notificationsRepo = $notificationsRepo;
$this->userRepository = $userRepository;
$this->language = $language;
}
/**
* Not exposed via JSON-RPC: it accepts an arbitrary $userId.
*/
public function getAllNotifications($userId, bool $showNewOnly = false, int $limitStart = 0, int $limitEnd = 100, array $filterOptions = []): false|array
{
return $this->notificationsRepo->getAllNotifications($userId, $showNewOnly, $limitStart, $limitEnd, $filterOptions);
}
/**
* @api
*/
public function addNotifications(array $notifications): ?bool
{
return $this->notificationsRepo->addNotifications($notifications);
}
/**
* consumeFlashNotification - reads the pending growl/flash notification from the
* session, clears the relevant session keys (read-once semantics) and returns the
* assembled payload.
*
* Returns null when there is no pending notification.
*
* @return array{notification: string, type: string, eventId: string}|null
*
* @api
*/
public function consumeFlashNotification(): ?array
{
if (session('notification') == '') {
return null;
}
$notificationArray = [
'notification' => session('notification'),
'type' => session('notificationType') ?? '',
'eventId' => session('eventId') ?? '',
];
session(['notification' => '']);
session(['notificationType' => '']);
session(['eventId' => '']);
return $notificationArray;
}
/**
* Marks a notification (or 'all') read for the CURRENT (session) user.
*
* JSON-RPC entry point: derives the user from the session so a caller
* cannot mark another user's notifications read.
*
* @param int|string $id A notification id, or 'all'
* @return bool True on success
*
* @api
*/
public function markRead($id): bool
{
return $this->markNotificationRead($id, session('userdata.id'));
}
/**
* Not exposed via JSON-RPC (accepts an arbitrary $userId). Use markRead().
*/
public function markNotificationRead($id, $userId): bool
{
if ($id == 'all') {
return $this->notificationsRepo->markAllNotificationRead($userId);
}
// Scope the update by user so a caller cannot mark another user's notification read.
return $this->notificationsRepo->markNotificationRead($id, $userId);
}
/**
* Flip a previously-read notification back to unread for the authenticated
* (session) user. Powers the swipe-to-mark-unread inbox gesture on mobile —
* symmetric to markRead() so users can re-surface something they tapped open
* by accident.
*
* Session-scoped: the row is matched on (id, session user), so a caller
* cannot flip another user's notification unread by guessing its id. (The
* id is a global sequence, so an unscoped update would be an IDOR.)
*
* @param int $id The notification id to mark unread
* @return bool True on success
*
* @api
*/
public function markNotificationUnread(int $id): bool
{
$userId = (int) session('userdata.id');
if ($id <= 0 || $userId === 0) {
return false;
}
return $this->notificationsRepo->markNotificationUnread($id, $userId);
}
/**
* Unread notification count for the authenticated user. Mobile uses
* this for the app-icon badge and the inbox tab unread dot. Cheap
* because the (userId, read) composite index on zp_notifications
* makes it a fast count.
*
* @api
*/
public function getUnreadCount(): int
{
$userId = (int) session('userdata.id');
if ($userId === 0) {
return 0;
}
// Delegated to the Repository — Service stores the raw DbCore
// (no query-builder helpers), Repository stores the resolved
// Illuminate ConnectionInterface. Following the established
// pattern that all SQL lives in the Repository layer.
return $this->notificationsRepo->getUnreadCount($userId);
}
/**
* Inbox listing for the authenticated (session) user. Mobile's inbox tab
* calls this with {} and gets only its own notifications (newest first).
*
* Session-scoped on purpose: getAllNotifications() takes an arbitrary
* $userId and is deliberately NOT @api-exposed (it would let a caller read
* another user's inbox). This wrapper derives the user from the session,
* exactly like getUnreadCount()/markRead() do, so there is nothing to gate.
*
* (Mark-all-read is already covered by markRead('all').)
*
* Pagination is clamped and the repo's arbitrary column=>value filter
* passthrough is intentionally NOT exposed here — an @api caller gets a
* bounded page of its OWN rows only (no caller-supplied WHERE columns, no
* unbounded limit).
*
* @param int $showNewOnly 1 = only unread notifications, 0 = all
* @param int $limitStart Offset for paging (clamped to >= 0)
* @param int $limitEnd Page size (clamped to 1..100)
* @return array<int, array<string, mixed>> The session user's notifications, or [] when unauthenticated
*
* @api
*/
public function getInbox(int $showNewOnly = 0, int $limitStart = 0, int $limitEnd = 50): array
{
$userId = (int) session('userdata.id');
if ($userId === 0) {
return [];
}
// Clamp pagination so an @api caller cannot request an unbounded page.
$limitStart = max(0, $limitStart);
$limitEnd = min(max(1, $limitEnd), 100);
return $this->notificationsRepo->getAllNotifications($userId, (bool) $showNewOnly, $limitStart, $limitEnd) ?: [];
}
/**
* Register a mobile device's push token for the authenticated user.
* Mobile calls this on every login (idempotent). The push fields
* live directly on the bearer's zp_access_tokens row, so:
* - logout / token revoke deletes the push registration too
* (no orphan rows, no prune cron needed)
* - re-login auto-creates a fresh row that will be updated on the
* next registerPushToken call
*
* Per [[feedback-mobile-owns-explicit-rpc-params]] convention,
* userId is resolved server-side from session — we don't accept it
* from the client (a stolen bearer shouldn't be able to register
* push tokens on someone else's account).
*
* Provider:
* - 'fcm': raw Firebase Cloud Messaging registration token.
* Dispatched direct to FCM HTTP v1.
*
* No token-format validation. Bad tokens are caught at send-time
* by FCM (UNREGISTERED / INVALID_ARGUMENT) and we mark
* push_invalidated_at then. Pre-validating here would just shift a
* small class of failures earlier without saving any work.
*
* The push_provider column on zp_access_tokens is retained for
* forward compatibility, but only 'fcm' is accepted today.
*
* @param string $token FCM registration token
* @param string $platform 'ios' or 'android'
* @param string|null $deviceName Ignored — kept for backwards-
* compat with mobile clients that
* still send it; the device name
* lives on zp_access_tokens.name
* already (set at login time)
* @param string $provider Must be 'fcm' (default 'fcm')
*
* @api
*/
public function registerPushToken(string $token, string $platform, ?string $deviceName = null, string $provider = 'fcm'): bool
{
$userId = (int) session('userdata.id');
if ($userId === 0) {
return false;
}
if (! in_array($platform, ['ios', 'android'], true)) {
return false;
}
if ($provider !== 'fcm') {
return false;
}
if ($token === '') {
return false;
}
$accessTokenId = $this->resolveCurrentAccessTokenId($userId);
if ($accessTokenId === null) {
return false;
}
return \Illuminate\Support\Facades\DB::table('zp_access_tokens')
->where('id', $accessTokenId)
->update([
'push_token' => $token,
'push_platform' => $platform,
'push_provider' => $provider,
'push_token_updated_at' => now(),
'push_invalidated_at' => null,
]) > 0;
}
/**
* Unregister this device's push token (called on mobile logout
* BEFORE the bearer is cleared, so the access token row is still
* resolvable). Soft-delete via push_invalidated_at — preserves the
* access-token row itself for the rest of the logout sequence.
*
* @param string $token The push token being unregistered. Kept
* for backwards-compat with mobile clients
* that pass it; we only need the bearer to
* identify the row, but a mismatch between
* passed-in token and stored token signals
* a race we should ignore (return true
* either way so logout doesn't fail).
*
* @api
*/
public function unregisterPushToken(string $token): bool
{
$userId = (int) session('userdata.id');
if ($userId === 0) {
return false;
}
$accessTokenId = $this->resolveCurrentAccessTokenId($userId);
if ($accessTokenId === null) {
return false;
}
\Illuminate\Support\Facades\DB::table('zp_access_tokens')
->where('id', $accessTokenId)
->update(['push_invalidated_at' => now()]);
return true;
}
/**
* Resolve the zp_access_tokens.id for the bearer that authenticated
* the current request. Tries Sanctum's currentAccessToken() first;
* falls back to the most-recently-used row for the user when the
* Sanctum guard isn't bound (legacy session-only auth path).
*
* Returns null only when no rows exist for the user — at that point
* the caller can't register push without a row to attach it to.
*/
private function resolveCurrentAccessTokenId(int $userId): ?int
{
try {
$user = auth()->user();
if ($user !== null && method_exists($user, 'currentAccessToken')) {
$current = $user->currentAccessToken();
if ($current !== null && isset($current->id)) {
return (int) $current->id;
}
}
} catch (\Throwable $e) {
// Sanctum guard not bound or user model doesn't support
// currentAccessToken — fall through to the lookup below.
}
$row = \Illuminate\Support\Facades\DB::table('zp_access_tokens')
->where('tokenable_id', $userId)
->orderByDesc('last_used_at')
->orderByDesc('id')
->first();
return $row !== null ? (int) $row->id : null;
}
/**
* @throws BindingResolutionException
*
* @api
*/
public function processMentions(string $content, string $module, int $moduleId, int $authorId, string $url): void
{
$dom = new DOMDocument;
// Content may not be well formatted. Suppress warnings.
@$dom->loadHTML($content);
$links = $dom->getElementsByTagName('a');
$author = $this->userRepository->getUser($authorId);
if ($author === false) {
return;
}
$authorName = htmlspecialchars(NameSanitizer::clean($author['firstname'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
for ($i = 0; $i < $links->count(); $i++) {
$taggedUser = $links->item($i)->getAttribute('data-tagged-user-id');
if ($taggedUser !== '' && is_numeric($taggedUser)) {
// Check if user was mentioned before
$userMentions = $this->getAllNotifications(
$taggedUser,
false,
0,
10,
['type' => 'mention', 'module' => $module, 'moduleId' => $moduleId]
);
if ($userMentions === false || (is_array($userMentions) && count($userMentions) == 0)) {
$notification = [
'userId' => $taggedUser,
'read' => '0',
'type' => 'mention',
'module' => $module,
'moduleId' => $moduleId,
'message' => sprintf($this->language->__('text.x_mentioned_you'), $authorName),
'datetime' => date('Y-m-d H:i:s'),
'url' => $url,
'authorId' => $authorId,
];
$this->addNotifications([$notification]);
// send email
$mailer = app()->make(MailerCore::class);
$mailer->setContext('notify_project_users');
$subject = sprintf($this->language->__('text.x_mentioned_you'), $authorName);
$mailer->setSubject($subject);
$emailMessage = $subject.' <a href="'.$url.'">'.$this->language->__('text.click_here').'</a>';
$mailer->setHtml($emailMessage);
$taggedUserObject = $this->userRepository->getUser($taggedUser);
if (isset($taggedUserObject['username'])) {
$mailer->sendMail([$taggedUserObject['username']], 'Leantime');
}
}
}
}
}
}

View File

@@ -0,0 +1,323 @@
<?php
namespace Leantime\Domain\Notifications\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
/**
* Push — sends mobile push notifications via FCM HTTP v1.
*
* Sibling to Messengers (Slack/Discord/Mattermost/Zulip webhook
* dispatcher). Lives in the same Notifications service folder and is
* meant to be wired into the same notification trigger paths.
*
* Token source: zp_access_tokens rows where push_token is set and
* push_invalidated_at is null. The push_provider column is retained
* for forward compatibility but only 'fcm' is dispatched today.
*
* Token lifecycle:
* - Mobile registers on login → push_token populated on the bearer's
* zp_access_tokens row.
* - FCM rejects token with UNREGISTERED / INVALID_ARGUMENT → we set
* push_invalidated_at on that row. Subsequent sends skip it.
* - Mobile logout → Sanctum revokes the access_tokens row entirely;
* push registration dies with it. No prune cron needed.
*
* Configuration (env / .env):
* - LEAN_PUSH_FCM_CREDENTIALS_PATH — absolute path to the Firebase
* service-account JSON (downloaded from Firebase Console > Project
* Settings > Service Accounts > Generate New Private Key).
* - LEAN_PUSH_FCM_PROJECT_ID — Firebase project id (the
* 'project_id' field inside the same JSON works; this lets
* operators override it cleanly).
*
* Without these set, sends silently no-op.
*/
class Push
{
private const FCM_OAUTH_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
private const FCM_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
private const FCM_SEND_URL_TEMPLATE = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
/**
* Cache key for the FCM OAuth access token. Tokens are valid for
* 3600s; we cache for 3540s (one minute of safety margin).
*/
private const FCM_TOKEN_CACHE_KEY = 'leantime.push.fcm.oauth_token';
private const FCM_TOKEN_CACHE_TTL = 3540;
private Client $httpClient;
public function __construct(Client $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* Dispatch a push notification to all valid push tokens for the
* given recipient user IDs.
*
* @param array<int> $userIds Recipient user IDs. The send is
* fan-out across every (user, device)
* pair — a user with N devices gets
* N pushes.
* @param string $title Banner title.
* @param string $body Banner body.
* @param array $data Custom payload routed to the mobile deeplink
* router. Common shape:
* module => 'tickets'|'comments'|...
* moduleId => entity id (string|int)
* parentTicketId => for comments: their ticket id
* url => web fallback URL
* Values get string-cast for FCM compatibility
* (FCM data fields must be strings).
*/
public function send(array $userIds, string $title, string $body, array $data = []): void
{
if (empty($userIds)) {
return;
}
$rows = \Illuminate\Support\Facades\DB::table('zp_access_tokens')
->whereIn('tokenable_id', $userIds)
->whereNotNull('push_token')
->whereNull('push_invalidated_at')
->get(['id', 'push_token', 'push_provider', 'push_platform', 'tokenable_id']);
if ($rows->isEmpty()) {
return;
}
foreach ($rows as $row) {
// push_provider is retained on the row for forward compat
// but only 'fcm' is dispatched. Unknown providers no-op
// silently rather than throw — keeps the table flexible
// without breaking the broadcast.
if ($row->push_provider !== 'fcm') {
continue;
}
try {
$this->sendFcm($row, $title, $body, $data);
} catch (\Throwable $e) {
// One bad row doesn't kill the broadcast. sendFcm marks
// push_invalidated_at on known-dead tokens (UNREGISTERED
// / INVALID_ARGUMENT); anything else gets logged for ops
// to look at later.
Log::warning('Push send failed for access_token #'.$row->id.': '.$e->getMessage());
}
}
}
/**
* Convenience for callers that already have a NotificationModel:
* extracts title/body/data from it and forwards to send().
*/
public function sendFromNotification(NotificationModel $notification, array $userIds): void
{
$title = $notification->subject !== '' ? $notification->subject : 'Leantime';
$body = $notification->message !== '' ? $notification->message : '';
// Module is a literal string ('tickets'/'comments'/'goalcanvas'/…)
// on the NotificationModel — see Models/Notification.php:81.
$module = $notification->module ?? '';
// Entity id is nested inside the entity payload, which can be
// either an array or an object (legacy: pre-model entities are
// arrays, newer ones are objects). Projects::notifyProjectUsers
// handles both shapes the same way at lines 334-357; we mirror
// that so the mobile deeplink can route by module + id.
$moduleId = '';
if (isset($notification->entity)) {
if (is_array($notification->entity) && isset($notification->entity['id'])) {
$moduleId = (string) $notification->entity['id'];
} elseif (is_object($notification->entity) && isset($notification->entity->id)) {
$moduleId = (string) $notification->entity->id;
}
}
// url is bool|array on the model: false when there's no web
// link, ['url' => …, 'text' => …] when there is. Extract the
// bare URL string so mobile receives a usable href.
$url = '';
if (is_array($notification->url) && isset($notification->url['url'])) {
$url = (string) $notification->url['url'];
}
$data = [
'module' => $module,
'moduleId' => $moduleId,
'url' => $url,
];
$this->send($userIds, $title, $body, $data);
}
/**
* FCM HTTP v1 send. Requires LEAN_PUSH_FCM_CREDENTIALS_PATH and
* LEAN_PUSH_FCM_PROJECT_ID to be set. Silently no-ops if not
* configured — admins who haven't set up FCM shouldn't get errors
* thrown at them; their pushes just don't deliver.
*/
private function sendFcm($row, string $title, string $body, array $data): void
{
$credentialsPath = (string) env('LEAN_PUSH_FCM_CREDENTIALS_PATH', '');
$projectId = (string) env('LEAN_PUSH_FCM_PROJECT_ID', '');
if ($credentialsPath === '' || $projectId === '' || ! is_readable($credentialsPath)) {
return;
}
$accessToken = $this->fetchFcmAccessToken($credentialsPath);
if ($accessToken === null) {
return;
}
// FCM data fields must be strings. Cast everything explicitly
// — easy to forget and surface as "InvalidValue" errors later.
$stringData = [];
foreach ($data as $k => $v) {
$stringData[(string) $k] = is_scalar($v) ? (string) $v : json_encode($v);
}
$payload = [
'message' => [
'token' => $row->push_token,
'notification' => [
'title' => $title,
'body' => $body,
],
'data' => $stringData,
],
];
try {
$this->httpClient->post(
sprintf(self::FCM_SEND_URL_TEMPLATE, $projectId),
[
'headers' => [
'Authorization' => 'Bearer '.$accessToken,
'Content-Type' => 'application/json',
],
'json' => $payload,
'timeout' => 10,
]
);
} catch (ClientException $e) {
$response = $e->getResponse();
$bodyText = (string) $response->getBody();
// FCM signals dead tokens via UNREGISTERED (404) or
// INVALID_ARGUMENT (400 with that specific error code).
// Mark the row invalidated so we stop trying.
if (
str_contains($bodyText, 'UNREGISTERED')
|| str_contains($bodyText, 'INVALID_ARGUMENT')
|| ($response !== null && $response->getStatusCode() === 404)
) {
$this->invalidateToken((int) $row->id);
return;
}
throw $e;
}
}
/**
* Mark an access-token row's push registration as invalid. Called
* when a provider tells us the token is dead. Doesn't touch the
* access token itself — only the push fields.
*/
private function invalidateToken(int $accessTokenId): void
{
\Illuminate\Support\Facades\DB::table('zp_access_tokens')
->where('id', $accessTokenId)
->update(['push_invalidated_at' => now()]);
}
/**
* Get a Google OAuth access token for FCM. Builds a JWT with the
* service-account credentials, exchanges it for an OAuth token,
* and caches the token until just before expiry.
*
* Returns null if anything fails — caller silently skips the send.
*/
private function fetchFcmAccessToken(string $credentialsPath): ?string
{
$cached = Cache::get(self::FCM_TOKEN_CACHE_KEY);
if (is_string($cached) && $cached !== '') {
return $cached;
}
try {
$json = file_get_contents($credentialsPath);
if ($json === false) {
return null;
}
$creds = json_decode($json, true);
if (! is_array($creds) || ! isset($creds['client_email'], $creds['private_key'])) {
return null;
}
$now = time();
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
$claims = [
'iss' => $creds['client_email'],
'scope' => self::FCM_OAUTH_SCOPE,
'aud' => self::FCM_OAUTH_TOKEN_URL,
'exp' => $now + 3600,
'iat' => $now,
];
$headerB64 = $this->base64UrlEncode(json_encode($header));
$claimsB64 = $this->base64UrlEncode(json_encode($claims));
$signingInput = $headerB64.'.'.$claimsB64;
$signature = '';
$privateKey = openssl_pkey_get_private($creds['private_key']);
if ($privateKey === false) {
return null;
}
$signed = openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256);
if (! $signed) {
return null;
}
$signatureB64 = $this->base64UrlEncode($signature);
$jwt = $signingInput.'.'.$signatureB64;
$response = $this->httpClient->post(self::FCM_OAUTH_TOKEN_URL, [
'form_params' => [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
],
'timeout' => 10,
]);
$tokenResponse = json_decode((string) $response->getBody(), true);
if (! is_array($tokenResponse) || ! isset($tokenResponse['access_token'])) {
return null;
}
$accessToken = (string) $tokenResponse['access_token'];
Cache::put(self::FCM_TOKEN_CACHE_KEY, $accessToken, self::FCM_TOKEN_CACHE_TTL);
return $accessToken;
} catch (\Throwable $e) {
Log::warning('Push: failed to fetch FCM OAuth token: '.$e->getMessage());
return null;
}
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}