OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
541
app/Domain/Notifications/Services/Messengers.php
Normal file
541
app/Domain/Notifications/Services/Messengers.php
Normal 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;
|
||||
}
|
||||
}
|
||||
111
app/Domain/Notifications/Services/News.php
Normal file
111
app/Domain/Notifications/Services/News.php
Normal 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;
|
||||
}
|
||||
}
|
||||
406
app/Domain/Notifications/Services/Notifications.php
Normal file
406
app/Domain/Notifications/Services/Notifications.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
323
app/Domain/Notifications/Services/Push.php
Normal file
323
app/Domain/Notifications/Services/Push.php
Normal 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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user