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,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