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:
root
2026-05-24 10:26:36 +03:00
commit df4e2dddec
17 changed files with 1005 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/vendor/
/.idea/
*.swp
+89
View File
@@ -0,0 +1,89 @@
# F7 Push
Mobile push delivery for **F7cloud** accounts via Firebase Cloud Messaging.
Portable: install on any F7cloud server, configure via `occ`, no hardcoded hostnames.
## Install on a server
```bash
git clone git@git.f7cloud.ru:root/f7push.git /var/www/f7cloud/apps/f7push
cd /var/www/f7cloud
sudo -u www-data php occ app:enable f7push
```
## Configuration (`occ`)
```bash
# Enable / disable
sudo -u www-data php occ config:app:set f7push enabled --value=yes
# Firebase (service account JSON from Firebase Console, one line or file)
sudo -u www-data php occ config:app:set f7push firebase_project_id --value=YOUR_PROJECT_ID
sudo -u www-data php occ config:app:set f7push firebase_credentials --value='{"type":"service_account",...}'
# Optional: default URL when notification has no link
sudo -u www-data php occ config:app:set f7push default_click_url --value=https://YOUR-SERVER.f7cloud.ru
# Filter sources (comma-separated app ids, or * for all)
sudo -u www-data php occ config:app:set f7push enabled_sources --value='spreed,f7support,files'
# Relay F7cloud bell notifications to push
sudo -u www-data php occ config:app:set f7push listen_notifications --value=yes
# API secret (auto-created on first server push if empty, or set manually):
sudo -u www-data php occ config:app:set f7push api_secret --value='YOUR_SECRET'
# Or: bash scripts/generate-secret.sh --regenerate
```
## API (OCS)
Base: `/ocs/v2.php/apps/f7push/api/v1/`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/devices` | User session | Register FCM token |
| DELETE | `/devices/{deviceId}` | User session | Unregister device |
| GET | `/devices` | User session | List own devices |
| GET | `/status` | Public | Push enabled / Firebase configured |
| POST | `/push` | `X-F7-Push-Secret` | Send push to user account |
| POST | `/push/test` | User session | Test push to own devices |
### Register device (APK)
```json
POST /ocs/v2.php/apps/f7push/api/v1/devices
{
"deviceId": "stable-android-id",
"fcmToken": "...",
"platform": "android",
"clientApp": "f7cloud-apk"
}
```
### Send push (f7support / other apps)
```json
POST /ocs/v2.php/apps/f7push/api/v1/push
Header: X-F7-Push-Secret: <secret>
{
"userId": "username",
"title": "Support",
"body": "New reply",
"url": "https://server/apps/f7support",
"source": "f7support",
"priority": "normal"
}
```
## Android APK
Repository: `android-webview` on the same server.
1. Add `google-services.json` from Firebase (`applicationId` `ru.forbion.f7cloud`).
2. Build APK; on login the app registers FCM token via `/devices`.
## Version
0.1.0 — initial release (device registry, FCM send, notification relay, API).
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<info>
<id>f7push</id>
<name>F7 Push</name>
<summary>Mobile push delivery for F7cloud accounts (Firebase)</summary>
<description>Registers F7cloud APK devices and sends Firebase push notifications per user account. Portable across F7cloud servers via occ configuration.</description>
<version>0.1.0</version>
<licence>AGPL</licence>
<author>F7cloud team</author>
<namespace>F7Push</namespace>
<category>tools</category>
<dependencies>
<f7cloud min-version="32" max-version="32"/>
</dependencies>
</info>
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
return [
'routes' => [],
'ocs' => [
[
'name' => 'device_api#register',
'url' => '/api/v{version}/devices',
'verb' => 'POST',
'requirements' => ['version' => '1'],
],
[
'name' => 'device_api#unregister',
'url' => '/api/v{version}/devices/{deviceId}',
'verb' => 'DELETE',
'requirements' => ['version' => '1'],
],
[
'name' => 'device_api#listDevices',
'url' => '/api/v{version}/devices',
'verb' => 'GET',
'requirements' => ['version' => '1'],
],
[
'name' => 'push_api#send',
'url' => '/api/v{version}/push',
'verb' => 'POST',
'requirements' => ['version' => '1'],
],
[
'name' => 'push_api#test',
'url' => '/api/v{version}/push/test',
'verb' => 'POST',
'requirements' => ['version' => '1'],
],
[
'name' => 'config_api#status',
'url' => '/api/v{version}/status',
'verb' => 'GET',
'requirements' => ['version' => '1'],
],
],
];
+36
View File
@@ -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;
}
}
+29
View File
@@ -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);
});
}
}
+34
View File
@@ -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(),
]);
}
}
+95
View File
@@ -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]);
}
}
+91
View File
@@ -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);
}
}
+44
View File
@@ -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',
];
}
+53
View File
@@ -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;
}
}
+17
View File
@@ -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,
) {
}
}
+80
View File
@@ -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);
}
}
+155
View File
@@ -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), '+/', '-_'), '=');
}
}
+146
View File
@@ -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,
));
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Print or set f7push API secret on this F7cloud server.
set -euo pipefail
cd /var/www/f7cloud
if [[ "${1:-}" == "--regenerate" ]]; then
SECRET=$(openssl rand -base64 36 | tr -d '/+=' | head -c 48)
sudo -u www-data php occ config:app:set f7push api_secret --value="$SECRET"
echo "$SECRET"
else
sudo -u www-data php occ config:app:get f7push api_secret 2>/dev/null || true
echo "(If empty, call POST /push once or run with --regenerate)"
fi