commit df4e2dddec136367e1d32a6353ce493cde22cd4c Author: root Date: Sun May 24 10:26:36 2026 +0300 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8162925 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/.idea/ +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..97e378f --- /dev/null +++ b/README.md @@ -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: + +{ + "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). diff --git a/appinfo/info.xml b/appinfo/info.xml new file mode 100644 index 0000000..67914ab --- /dev/null +++ b/appinfo/info.xml @@ -0,0 +1,15 @@ + + + f7push + F7 Push + Mobile push delivery for F7cloud accounts (Firebase) + Registers F7cloud APK devices and sends Firebase push notifications per user account. Portable across F7cloud servers via occ configuration. + 0.1.0 + AGPL + F7cloud team + F7Push + tools + + + + diff --git a/appinfo/routes.php b/appinfo/routes.php new file mode 100644 index 0000000..f942946 --- /dev/null +++ b/appinfo/routes.php @@ -0,0 +1,45 @@ + [], + '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'], + ], + ], +]; diff --git a/lib/App/NotificationRelay.php b/lib/App/NotificationRelay.php new file mode 100644 index 0000000..20c5427 --- /dev/null +++ b/lib/App/NotificationRelay.php @@ -0,0 +1,36 @@ +config->isEnabled() || !$this->config->listenNotifications()) { + return; + } + $this->pushDispatcher->dispatchFromNotification($notification); + } + + public function markProcessed(INotification $notification): void { + } + + public function getCount(INotification $notification): int { + return 0; + } +} diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php new file mode 100644 index 0000000..709448e --- /dev/null +++ b/lib/AppInfo/Application.php @@ -0,0 +1,29 @@ +injectFn(function (IManager $notificationManager): void { + $notificationManager->registerApp(NotificationRelay::class); + }); + } +} diff --git a/lib/Controller/ConfigApiController.php b/lib/Controller/ConfigApiController.php new file mode 100644 index 0000000..decc40b --- /dev/null +++ b/lib/Controller/ConfigApiController.php @@ -0,0 +1,34 @@ + $this->config->isEnabled(), + 'firebaseConfigured' => $this->config->isFirebaseConfigured(), + 'defaultClickUrl' => $this->config->getDefaultClickUrl(), + ]); + } +} diff --git a/lib/Controller/DeviceApiController.php b/lib/Controller/DeviceApiController.php new file mode 100644 index 0000000..7e862e7 --- /dev/null +++ b/lib/Controller/DeviceApiController.php @@ -0,0 +1,95 @@ +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]); + } +} diff --git a/lib/Controller/PushApiController.php b/lib/Controller/PushApiController.php new file mode 100644 index 0000000..ed5c1e0 --- /dev/null +++ b/lib/Controller/PushApiController.php @@ -0,0 +1,91 @@ +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); + } +} diff --git a/lib/Db/Device.php b/lib/Db/Device.php new file mode 100644 index 0000000..4c2c5c3 --- /dev/null +++ b/lib/Db/Device.php @@ -0,0 +1,44 @@ + 'integer', + 'userId' => 'string', + 'deviceId' => 'string', + 'fcmToken' => 'string', + 'platform' => 'string', + 'clientApp' => 'string', + 'createdAt' => 'integer', + 'lastSeen' => 'integer', + ]; +} diff --git a/lib/Db/DeviceMapper.php b/lib/Db/DeviceMapper.php new file mode 100644 index 0000000..dee2732 --- /dev/null +++ b/lib/Db/DeviceMapper.php @@ -0,0 +1,53 @@ + */ +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(); + } +} diff --git a/lib/Migration/Version1000Date20260524100000.php b/lib/Migration/Version1000Date20260524100000.php new file mode 100644 index 0000000..dc4c8a3 --- /dev/null +++ b/lib/Migration/Version1000Date20260524100000.php @@ -0,0 +1,61 @@ +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; + } +} diff --git a/lib/Model/PushMessage.php b/lib/Model/PushMessage.php new file mode 100644 index 0000000..298605a --- /dev/null +++ b/lib/Model/PushMessage.php @@ -0,0 +1,17 @@ +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); + } +} diff --git a/lib/Service/FirebaseClient.php b/lib/Service/FirebaseClient.php new file mode 100644 index 0000000..3f60237 --- /dev/null +++ b/lib/Service/FirebaseClient.php @@ -0,0 +1,155 @@ +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 $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), '+/', '-_'), '='); + } +} diff --git a/lib/Service/PushDispatcher.php b/lib/Service/PushDispatcher.php new file mode 100644 index 0000000..c65ae6d --- /dev/null +++ b/lib/Service/PushDispatcher.php @@ -0,0 +1,146 @@ +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, + )); + } +} diff --git a/scripts/generate-secret.sh b/scripts/generate-secret.sh new file mode 100644 index 0000000..3c3f14e --- /dev/null +++ b/scripts/generate-secret.sh @@ -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