Add f7push v0.1: FCM device registry and push API for F7cloud.
Portable occ-based config, OCS endpoints for APK registration, notification relay, and server-side push dispatch.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\App;
|
||||
|
||||
use OCA\F7Push\Service\ConfigService;
|
||||
use OCA\F7Push\Service\PushDispatcher;
|
||||
use OCP\Notification\IApp;
|
||||
use OCP\Notification\INotification;
|
||||
|
||||
/**
|
||||
* Relays F7cloud account notifications to registered mobile devices via Firebase.
|
||||
* Does not store notifications (handled by the notifications app).
|
||||
*/
|
||||
class NotificationRelay implements IApp {
|
||||
public function __construct(
|
||||
private PushDispatcher $pushDispatcher,
|
||||
private ConfigService $config,
|
||||
) {
|
||||
}
|
||||
|
||||
public function notify(INotification $notification): void {
|
||||
if (!$this->config->isEnabled() || !$this->config->listenNotifications()) {
|
||||
return;
|
||||
}
|
||||
$this->pushDispatcher->dispatchFromNotification($notification);
|
||||
}
|
||||
|
||||
public function markProcessed(INotification $notification): void {
|
||||
}
|
||||
|
||||
public function getCount(INotification $notification): int {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\AppInfo;
|
||||
|
||||
use OCA\F7Push\App\NotificationRelay;
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
use OCP\Notification\IManager;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'f7push';
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct(self::APP_ID);
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
$context->injectFn(function (IManager $notificationManager): void {
|
||||
$notificationManager->registerApp(NotificationRelay::class);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Controller;
|
||||
|
||||
use OCA\F7Push\Service\ConfigService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
|
||||
class ConfigApiController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private ConfigService $config,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/** Public status for mobile clients (no secrets). */
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
public function status(): DataResponse {
|
||||
return new DataResponse([
|
||||
'enabled' => $this->config->isEnabled(),
|
||||
'firebaseConfigured' => $this->config->isFirebaseConfigured(),
|
||||
'defaultClickUrl' => $this->config->getDefaultClickUrl(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Controller;
|
||||
|
||||
use OCA\F7Push\Service\PushDispatcher;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
class DeviceApiController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private IUserSession $userSession,
|
||||
private PushDispatcher $pushDispatcher,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or update FCM device token for the logged-in F7cloud account.
|
||||
*
|
||||
* JSON: { "deviceId", "fcmToken", "platform?", "clientApp?" }
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
public function register(): DataResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new DataResponse(['error' => 'unauthorized'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$params = $this->request->getParams();
|
||||
$deviceId = trim((string)($params['deviceId'] ?? ''));
|
||||
$fcmToken = trim((string)($params['fcmToken'] ?? ''));
|
||||
if ($deviceId === '' || $fcmToken === '') {
|
||||
return new DataResponse(['error' => 'deviceId and fcmToken required'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$platform = trim((string)($params['platform'] ?? 'android'));
|
||||
$clientApp = trim((string)($params['clientApp'] ?? 'f7cloud-apk'));
|
||||
|
||||
$device = $this->pushDispatcher->registerDevice(
|
||||
$user->getUID(),
|
||||
$deviceId,
|
||||
$fcmToken,
|
||||
$platform !== '' ? $platform : 'android',
|
||||
$clientApp !== '' ? $clientApp : 'f7cloud-apk',
|
||||
);
|
||||
|
||||
return new DataResponse([
|
||||
'success' => true,
|
||||
'deviceId' => $device->getDeviceId(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
public function unregister(string $deviceId): DataResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new DataResponse(['error' => 'unauthorized'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$this->pushDispatcher->unregisterDevice($user->getUID(), $deviceId);
|
||||
return new DataResponse(['success' => true]);
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
public function listDevices(): DataResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new DataResponse(['error' => 'unauthorized'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$devices = $this->pushDispatcher->listDevices($user->getUID());
|
||||
$list = array_map(static function ($device) {
|
||||
return [
|
||||
'deviceId' => $device->getDeviceId(),
|
||||
'platform' => $device->getPlatform(),
|
||||
'clientApp' => $device->getClientApp(),
|
||||
'lastSeen' => $device->getLastSeen(),
|
||||
];
|
||||
}, $devices);
|
||||
|
||||
return new DataResponse(['devices' => $list]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Controller;
|
||||
|
||||
use OCA\F7Push\Model\PushMessage;
|
||||
use OCA\F7Push\Service\ConfigService;
|
||||
use OCA\F7Push\Service\PushDispatcher;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
class PushApiController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private ConfigService $config,
|
||||
private PushDispatcher $pushDispatcher,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-to-server push (f7support and other F7cloud apps).
|
||||
* Header: X-F7-Push-Secret
|
||||
*
|
||||
* JSON: { "userId", "title", "body", "url?", "source?", "priority?" }
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
public function send(): DataResponse {
|
||||
if (!$this->authorizeSecret()) {
|
||||
return new DataResponse(['error' => 'forbidden'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$params = $this->request->getParams();
|
||||
$userId = trim((string)($params['userId'] ?? ''));
|
||||
$title = trim((string)($params['title'] ?? ''));
|
||||
$body = trim((string)($params['body'] ?? ''));
|
||||
if ($userId === '' || $title === '') {
|
||||
return new DataResponse(['error' => 'userId and title required'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$sent = $this->pushDispatcher->dispatch(new PushMessage(
|
||||
userId: $userId,
|
||||
title: $title,
|
||||
body: $body,
|
||||
source: trim((string)($params['source'] ?? 'api')),
|
||||
priority: trim((string)($params['priority'] ?? 'normal')),
|
||||
url: isset($params['url']) ? trim((string)$params['url']) : null,
|
||||
));
|
||||
|
||||
return new DataResponse(['success' => true, 'sent' => $sent]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test push to devices of the current user (admin or self).
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
public function test(): DataResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new DataResponse(['error' => 'unauthorized'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$sent = $this->pushDispatcher->dispatch(new PushMessage(
|
||||
userId: $user->getUID(),
|
||||
title: 'F7 Push',
|
||||
body: 'Test notification from F7cloud',
|
||||
source: 'f7push',
|
||||
priority: 'normal',
|
||||
url: $this->config->getDefaultClickUrl(),
|
||||
));
|
||||
|
||||
return new DataResponse(['success' => true, 'sent' => $sent]);
|
||||
}
|
||||
|
||||
private function authorizeSecret(): bool {
|
||||
$header = $this->request->getHeader('X-F7-Push-Secret');
|
||||
if ($header === '') {
|
||||
$header = (string)$this->request->getParam('secret', '');
|
||||
}
|
||||
return $this->config->verifyApiSecret($header);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method string getUserId()
|
||||
* @method void setUserId(string $userId)
|
||||
* @method string getDeviceId()
|
||||
* @method void setDeviceId(string $deviceId)
|
||||
* @method string getFcmToken()
|
||||
* @method void setFcmToken(string $fcmToken)
|
||||
* @method string getPlatform()
|
||||
* @method void setPlatform(string $platform)
|
||||
* @method string getClientApp()
|
||||
* @method void setClientApp(string $clientApp)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $createdAt)
|
||||
* @method int getLastSeen()
|
||||
* @method void setLastSeen(int $lastSeen)
|
||||
*/
|
||||
class Device extends Entity {
|
||||
protected $userId = '';
|
||||
protected $deviceId = '';
|
||||
protected $fcmToken = '';
|
||||
protected $platform = 'android';
|
||||
protected $clientApp = 'f7cloud-apk';
|
||||
protected $createdAt = 0;
|
||||
protected $lastSeen = 0;
|
||||
|
||||
protected static $propertyTypes = [
|
||||
'id' => 'integer',
|
||||
'userId' => 'string',
|
||||
'deviceId' => 'string',
|
||||
'fcmToken' => 'string',
|
||||
'platform' => 'string',
|
||||
'clientApp' => 'string',
|
||||
'createdAt' => 'integer',
|
||||
'lastSeen' => 'integer',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Db;
|
||||
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/** @extends QBMapper<Device> */
|
||||
class DeviceMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, 'f7push_devices', Device::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Device[]
|
||||
*/
|
||||
public function findByUser(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)));
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
public function findByUserAndDevice(string $userId, string $deviceId): Device {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)))
|
||||
->andWhere($qb->expr()->eq('device_id', $qb->createNamedParameter($deviceId, IQueryBuilder::PARAM_STR)));
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function deleteByUserAndDevice(string $userId, string $deviceId): void {
|
||||
try {
|
||||
$device = $this->findByUserAndDevice($userId, $deviceId);
|
||||
$this->delete($device);
|
||||
} catch (DoesNotExistException|MultipleObjectsReturnedException) {
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteAllForUser(string $userId): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version1000Date20260524100000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('f7push_devices')) {
|
||||
$table = $schema->createTable('f7push_devices');
|
||||
$table->addColumn('id', 'integer', [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'length' => 4,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('user_id', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
$table->addColumn('device_id', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 128,
|
||||
]);
|
||||
$table->addColumn('fcm_token', 'text', [
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->addColumn('platform', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 32,
|
||||
'default' => 'android',
|
||||
]);
|
||||
$table->addColumn('client_app', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
'default' => 'f7cloud-apk',
|
||||
]);
|
||||
$table->addColumn('created_at', 'integer', [
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('last_seen', 'integer', [
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addUniqueIndex(['user_id', 'device_id'], 'f7push_dev_user_device');
|
||||
$table->addIndex(['user_id'], 'f7push_dev_user');
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Model;
|
||||
|
||||
class PushMessage {
|
||||
public function __construct(
|
||||
public readonly string $userId,
|
||||
public readonly string $title,
|
||||
public readonly string $body,
|
||||
public readonly string $source = 'f7push',
|
||||
public readonly string $priority = 'normal',
|
||||
public readonly ?string $url = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Service;
|
||||
|
||||
use OCP\IConfig;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Security\ISecureRandom;
|
||||
|
||||
class ConfigService {
|
||||
public const APP_ID = 'f7push';
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private ISecureRandom $secureRandom,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isEnabled(): bool {
|
||||
return $this->config->getAppValue(self::APP_ID, 'enabled', 'yes') === 'yes';
|
||||
}
|
||||
|
||||
public function listenNotifications(): bool {
|
||||
return $this->config->getAppValue(self::APP_ID, 'listen_notifications', 'yes') === 'yes';
|
||||
}
|
||||
|
||||
public function getFirebaseProjectId(): string {
|
||||
return trim($this->config->getAppValue(self::APP_ID, 'firebase_project_id', ''));
|
||||
}
|
||||
|
||||
public function getFirebaseCredentials(): string {
|
||||
return trim($this->config->getAppValue(self::APP_ID, 'firebase_credentials', ''));
|
||||
}
|
||||
|
||||
public function isFirebaseConfigured(): bool {
|
||||
return $this->getFirebaseProjectId() !== '' && $this->getFirebaseCredentials() !== '';
|
||||
}
|
||||
|
||||
public function getApiSecret(): string {
|
||||
$secret = trim($this->config->getAppValue(self::APP_ID, 'api_secret', ''));
|
||||
if ($secret === '') {
|
||||
$secret = $this->secureRandom->generate(48, ISecureRandom::CHAR_ALPHANUMERIC);
|
||||
$this->config->setAppValue(self::APP_ID, 'api_secret', $secret);
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
public function regenerateApiSecret(): string {
|
||||
$secret = $this->secureRandom->generate(48, ISecureRandom::CHAR_ALPHANUMERIC);
|
||||
$this->config->setAppValue(self::APP_ID, 'api_secret', $secret);
|
||||
return $secret;
|
||||
}
|
||||
|
||||
public function isSourceEnabled(string $source): bool {
|
||||
$raw = $this->config->getAppValue(self::APP_ID, 'enabled_sources', '*');
|
||||
$raw = trim($raw);
|
||||
if ($raw === '' || $raw === '*') {
|
||||
return true;
|
||||
}
|
||||
$parts = array_map('trim', explode(',', strtolower($raw)));
|
||||
return in_array(strtolower($source), $parts, true);
|
||||
}
|
||||
|
||||
public function getDefaultClickUrl(): string {
|
||||
$configured = trim($this->config->getAppValue(self::APP_ID, 'default_click_url', ''));
|
||||
if ($configured !== '') {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
return rtrim($this->urlGenerator->getAbsoluteURL(''), '/');
|
||||
}
|
||||
|
||||
public function verifyApiSecret(?string $provided): bool {
|
||||
if ($provided === null || $provided === '') {
|
||||
return false;
|
||||
}
|
||||
return hash_equals($this->getApiSecret(), $provided);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Service;
|
||||
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\ICache;
|
||||
use OCP\ICacheFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class FirebaseClient {
|
||||
private const TOKEN_CACHE_KEY = 'f7push_fcm_access_token';
|
||||
private const TOKEN_TTL = 3300;
|
||||
|
||||
public function __construct(
|
||||
private ConfigService $config,
|
||||
private IClientService $clientService,
|
||||
private ICacheFactory $cacheFactory,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sendToToken(string $fcmToken, string $title, string $body, ?string $url, string $priority): bool {
|
||||
if (!$this->config->isFirebaseConfigured()) {
|
||||
$this->logger->info('f7push: Firebase not configured, skip FCM send', ['app' => 'f7push']);
|
||||
return false;
|
||||
}
|
||||
|
||||
$accessToken = $this->getAccessToken();
|
||||
if ($accessToken === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$projectId = $this->config->getFirebaseProjectId();
|
||||
$endpoint = 'https://fcm.googleapis.com/v1/projects/' . rawurlencode($projectId) . '/messages:send';
|
||||
|
||||
$notification = [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
];
|
||||
$data = [];
|
||||
if ($url !== null && $url !== '') {
|
||||
$data['url'] = $url;
|
||||
}
|
||||
$data['click_action'] = 'FLUTTER_NOTIFICATION_CLICK';
|
||||
|
||||
$message = [
|
||||
'message' => [
|
||||
'token' => $fcmToken,
|
||||
'notification' => $notification,
|
||||
'data' => $data,
|
||||
'android' => [
|
||||
'priority' => $priority === 'high' ? 'HIGH' : 'NORMAL',
|
||||
'notification' => [
|
||||
'channel_id' => $priority === 'high' ? 'f7cloud_calls' : 'f7cloud_default',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$client = $this->clientService->newClient();
|
||||
try {
|
||||
$response = $client->post($endpoint, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $accessToken,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => json_encode($message, JSON_THROW_ON_ERROR),
|
||||
'timeout' => 15,
|
||||
]);
|
||||
return $response->getStatusCode() >= 200 && $response->getStatusCode() < 300;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('f7push: FCM send failed', [
|
||||
'app' => 'f7push',
|
||||
'exception' => $e,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getAccessToken(): ?string {
|
||||
$cache = $this->getCache();
|
||||
$cached = $cache->get(self::TOKEN_CACHE_KEY);
|
||||
if (is_string($cached) && $cached !== '') {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$credentialsJson = $this->config->getFirebaseCredentials();
|
||||
try {
|
||||
/** @var array<string, mixed> $credentials */
|
||||
$credentials = json_decode($credentialsJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('f7push: invalid firebase_credentials JSON', ['app' => 'f7push', 'exception' => $e]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientEmail = (string)($credentials['client_email'] ?? '');
|
||||
$privateKey = (string)($credentials['private_key'] ?? '');
|
||||
if ($clientEmail === '' || $privateKey === '') {
|
||||
$this->logger->error('f7push: firebase_credentials missing client_email or private_key', ['app' => 'f7push']);
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$header = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'], JSON_THROW_ON_ERROR));
|
||||
$claims = $this->base64UrlEncode(json_encode([
|
||||
'iss' => $clientEmail,
|
||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'iat' => $now,
|
||||
'exp' => $now + 3600,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
$input = $header . '.' . $claims;
|
||||
|
||||
$signature = '';
|
||||
$ok = openssl_sign($input, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
if (!$ok) {
|
||||
$this->logger->error('f7push: openssl_sign failed for Firebase JWT', ['app' => 'f7push']);
|
||||
return null;
|
||||
}
|
||||
|
||||
$jwt = $input . '.' . $this->base64UrlEncode($signature);
|
||||
|
||||
$client = $this->clientService->newClient();
|
||||
try {
|
||||
$response = $client->post('https://oauth2.googleapis.com/token', [
|
||||
'body' => http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
]),
|
||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||
'timeout' => 15,
|
||||
]);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
$token = is_array($data) ? (string)($data['access_token'] ?? '') : '';
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
$cache->set(self::TOKEN_CACHE_KEY, $token, self::TOKEN_TTL);
|
||||
return $token;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('f7push: Firebase OAuth token exchange failed', ['app' => 'f7push', 'exception' => $e]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function getCache(): ICache {
|
||||
return $this->cacheFactory->createDistributed('f7push');
|
||||
}
|
||||
|
||||
private function base64UrlEncode(string $data): string {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Push\Service;
|
||||
|
||||
use OCA\F7Push\Db\Device;
|
||||
use OCA\F7Push\Db\DeviceMapper;
|
||||
use OCA\F7Push\Model\PushMessage;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Notification\INotification;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class PushDispatcher {
|
||||
public function __construct(
|
||||
private DeviceMapper $deviceMapper,
|
||||
private FirebaseClient $firebaseClient,
|
||||
private ConfigService $config,
|
||||
private ITimeFactory $timeFactory,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function registerDevice(
|
||||
string $userId,
|
||||
string $deviceId,
|
||||
string $fcmToken,
|
||||
string $platform,
|
||||
string $clientApp,
|
||||
): Device {
|
||||
$now = $this->timeFactory->getTime();
|
||||
try {
|
||||
$device = $this->deviceMapper->findByUserAndDevice($userId, $deviceId);
|
||||
$device->setFcmToken($fcmToken);
|
||||
$device->setPlatform($platform);
|
||||
$device->setClientApp($clientApp);
|
||||
$device->setLastSeen($now);
|
||||
return $this->deviceMapper->update($device);
|
||||
} catch (DoesNotExistException) {
|
||||
$device = new Device();
|
||||
$device->setUserId($userId);
|
||||
$device->setDeviceId($deviceId);
|
||||
$device->setFcmToken($fcmToken);
|
||||
$device->setPlatform($platform);
|
||||
$device->setClientApp($clientApp);
|
||||
$device->setCreatedAt($now);
|
||||
$device->setLastSeen($now);
|
||||
return $this->deviceMapper->insert($device);
|
||||
}
|
||||
}
|
||||
|
||||
public function unregisterDevice(string $userId, string $deviceId): void {
|
||||
$this->deviceMapper->deleteByUserAndDevice($userId, $deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Device[]
|
||||
*/
|
||||
public function listDevices(string $userId): array {
|
||||
return $this->deviceMapper->findByUser($userId);
|
||||
}
|
||||
|
||||
public function dispatch(PushMessage $message): int {
|
||||
if (!$this->config->isEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
if (!$this->config->isSourceEnabled($message->source)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$devices = $this->deviceMapper->findByUser($message->userId);
|
||||
if ($devices === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$url = $message->url ?? $this->config->getDefaultClickUrl();
|
||||
$sent = 0;
|
||||
foreach ($devices as $device) {
|
||||
$token = $device->getFcmToken();
|
||||
if ($token === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->firebaseClient->sendToToken($token, $message->title, $message->body, $url, $message->priority)) {
|
||||
$sent++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug('f7push: dispatched push', [
|
||||
'app' => 'f7push',
|
||||
'user' => $message->userId,
|
||||
'source' => $message->source,
|
||||
'sent' => $sent,
|
||||
'devices' => count($devices),
|
||||
]);
|
||||
|
||||
return $sent;
|
||||
}
|
||||
|
||||
public function dispatchFromNotification(INotification $notification): int {
|
||||
if (!$this->config->listenNotifications()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$user = $notification->getUser();
|
||||
if ($user === null || $user === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$source = $notification->getApp();
|
||||
if (!$this->config->isSourceEnabled($source)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$title = $notification->getParsedSubject();
|
||||
if ($title === '') {
|
||||
$title = $notification->getSubject();
|
||||
}
|
||||
$body = $notification->getParsedMessage();
|
||||
if ($body === '') {
|
||||
$body = $notification->getMessage();
|
||||
}
|
||||
if ($title === '' && $body === '') {
|
||||
return 0;
|
||||
}
|
||||
if ($title === '') {
|
||||
$title = $body;
|
||||
}
|
||||
|
||||
$link = $notification->getLink();
|
||||
if ($link !== '' && !str_starts_with($link, 'http://') && !str_starts_with($link, 'https://')) {
|
||||
$link = $this->config->getDefaultClickUrl() . $link;
|
||||
}
|
||||
|
||||
$priority = $source === 'spreed' ? 'high' : 'normal';
|
||||
|
||||
return $this->dispatch(new PushMessage(
|
||||
userId: $user,
|
||||
title: $title,
|
||||
body: $body,
|
||||
source: $source,
|
||||
priority: $priority,
|
||||
url: $link !== '' ? $link : null,
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user