diff --git a/assets/vue/App.vue b/assets/vue/App.vue index 8e84f70337e..53c49123fc9 100644 --- a/assets/vue/App.vue +++ b/assets/vue/App.vue @@ -186,5 +186,16 @@ watch( onMounted(async () => { mejsLoader() await securityStore.checkSession() + + if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("/service-worker.js") + .then((registration) => { + console.log("[PWA] Service Worker registered with scope:", registration.scope) + }) + .catch((error) => { + console.error("[PWA] Service Worker registration failed:", error) + }) + } }) diff --git a/assets/vue/components/social/UserProfileCard.vue b/assets/vue/components/social/UserProfileCard.vue index d9decef55dc..eb299c30c0f 100644 --- a/assets/vue/components/social/UserProfileCard.vue +++ b/assets/vue/components/social/UserProfileCard.vue @@ -96,6 +96,65 @@ +
+

+ + {{ t("Checking push subscription...") }} +

+ +
+ + + +
+
+ diff --git a/assets/vue/composables/usePushSubscription.js b/assets/vue/composables/usePushSubscription.js new file mode 100644 index 00000000000..6040127b4e9 --- /dev/null +++ b/assets/vue/composables/usePushSubscription.js @@ -0,0 +1,230 @@ +import { ref } from "vue" +import { usePlatformConfig } from "../store/platformConfig" +import { arrayBufferToBase64, urlBase64ToUint8Array } from "../utils/pushUtils.js" +import axios from "axios" + +export function usePushSubscription() { + const isSubscribed = ref(null) + const subscriptionInfo = ref(null) + const loading = ref(false) + const vapidPublicKey = ref("") + const pushEnabled = ref(false) + + function loadVapidKey() { + const platformConfigStore = usePlatformConfig() + const rawSettings = platformConfigStore.getSetting("platform.push_notification_settings") + + if (rawSettings) { + try { + const decoded = JSON.parse(rawSettings) + vapidPublicKey.value = decoded.vapid_public_key || "" + pushEnabled.value = !!decoded.enabled + } catch (e) { + console.error("Invalid JSON in push_notification_settings", e) + pushEnabled.value = false + } + } + } + + async function registerServiceWorker() { + if (!("serviceWorker" in navigator)) { + console.warn("[Push] Service Worker not supported.") + return null + } + + try { + return await navigator.serviceWorker.register("/service-worker.js") + } catch (e) { + console.error("[Push] Service Worker registration failed:", e) + return null + } + } + + async function checkSubscription(userId) { + loading.value = true + + try { + if (!userId) { + console.log("Cannot check push subscription without userId.") + isSubscribed.value = false + subscriptionInfo.value = null + loading.value = false + return false + } + + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + console.log("Push not supported.") + isSubscribed.value = false + subscriptionInfo.value = null + loading.value = false + return false + } + + // ensure the SW is registered + const registration = await registerServiceWorker() + if (!registration) { + console.warn("[Push] Could not register Service Worker. Skipping subscription check.") + loading.value = false + return false + } + + const sub = await registration.pushManager.getSubscription() + if (sub) { + const result = await axios.get( + `/api/push_subscriptions?endpoint=${encodeURIComponent(sub.endpoint)}&user.id=${userId}`, + ) + + if (result.data["hydra:member"].length > 0) { + const dbSub = result.data["hydra:member"][0] + isSubscribed.value = true + subscriptionInfo.value = { + id: dbSub.id, + endpoint: dbSub.endpoint, + p256dh: dbSub.publicKey, + auth: dbSub.authToken, + } + } else { + console.log("[Push] No matching subscription found in backend.") + isSubscribed.value = false + subscriptionInfo.value = { + endpoint: sub.endpoint, + p256dh: sub.getKey("p256dh") ? arrayBufferToBase64(sub.getKey("p256dh")) : null, + auth: sub.getKey("auth") ? arrayBufferToBase64(sub.getKey("auth")) : null, + } + } + } else { + console.log("[Push] No push subscription found in browser.") + isSubscribed.value = false + subscriptionInfo.value = null + } + } catch (e) { + if (e.response) { + console.error("[Push] Backend returned error:", e.response.status, e.response.data) + } else { + console.error("[Push] Network or unexpected error:", e.message) + } + isSubscribed.value = false + subscriptionInfo.value = null + } finally { + if (isSubscribed.value === null) { + isSubscribed.value = false + } + loading.value = false + } + } + + async function subscribe(userId) { + if (!userId) { + console.error("Cannot subscribe to push without userId.") + return false + } + + loading.value = true + + try { + const registration = await registerServiceWorker() + if (!registration) { + console.warn("[Push] Could not register Service Worker. Cannot subscribe.") + loading.value = false + return false + } + + const permission = await Notification.requestPermission() + console.log("[Push] Notification permission:", permission) + + if (permission !== "granted") { + console.warn("[Push] Notification permission denied.") + loading.value = false + return false + } + + const sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey.value), + }) + + const payload = { + endpoint: sub.endpoint, + publicKey: arrayBufferToBase64(sub.getKey("p256dh")), + authToken: arrayBufferToBase64(sub.getKey("auth")), + contentEncoding: "aesgcm", + userAgent: navigator.userAgent, + user: `/api/users/${userId}`, + } + + const response = await axios.post("/api/push_subscriptions", payload) + + if (response.data && response.data.id) { + subscriptionInfo.value = { + id: response.data.id, + endpoint: response.data.endpoint, + p256dh: response.data.publicKey, + auth: response.data.authToken, + } + } + + await checkSubscription(userId) + return true + } catch (e) { + console.error("Push subscription error:", e) + return false + } finally { + loading.value = false + } + } + + async function unsubscribe(userId) { + if (!userId) { + return false + } + + loading.value = true + + try { + const registration = await registerServiceWorker() + if (!registration) { + console.warn("[Push] Could not register Service Worker. Cannot unsubscribe.") + loading.value = false + return false + } + + const sub = await registration.pushManager.getSubscription() + + if (sub) { + await sub.unsubscribe() + + const result = await axios.get( + `/api/push_subscriptions?endpoint=${encodeURIComponent(sub.endpoint)}&user.id=${userId}`, + ) + + if (result.data["hydra:member"].length > 0) { + const id = result.data["hydra:member"][0].id + await axios.delete(`/api/push_subscriptions/${id}`) + console.log("[Push] Deleted backend subscription with id", id) + } else { + console.warn("Push subscription not found in backend for deletion.") + } + } else { + console.log("[Push] No subscription found in browser to unsubscribe.") + } + } catch (e) { + console.error("Error unsubscribing:", e) + } finally { + await checkSubscription(userId) + loading.value = false + } + } + + return { + isSubscribed, + subscriptionInfo, + subscribe, + unsubscribe, + loading, + checkSubscription, + loadVapidKey, + vapidPublicKey, + pushEnabled, + registerServiceWorker, + } +} diff --git a/assets/vue/utils/pushUtils.js b/assets/vue/utils/pushUtils.js new file mode 100644 index 00000000000..ab5779b930b --- /dev/null +++ b/assets/vue/utils/pushUtils.js @@ -0,0 +1,23 @@ +export function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +export function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); +} diff --git a/composer.json b/composer.json index dcedee48752..7611887a2c8 100755 --- a/composer.json +++ b/composer.json @@ -105,6 +105,7 @@ "maennchen/zipstream-php": "^2.1", "masterminds/html5": "^2.0", "michelf/php-markdown": "~1.8", + "minishlink/web-push": "^9.0", "mpdf/mpdf": "~8.0", "nelexa/zip": "^4.0", "nelmio/cors-bundle": "^2.2", diff --git a/composer.lock b/composer.lock index e2c949199e7..b0a0a4fbb81 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "00010bf1a816a35566eca50c4b860de4", + "content-hash": "f73a87f700cf29342a335ada120b83a3", "packages": [ { "name": "a2lix/auto-form-bundle", @@ -7510,6 +7510,71 @@ }, "time": "2021-10-09T03:03:47+00:00" }, + { + "name": "minishlink/web-push", + "version": "v9.0.2", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "9c9623bf2f455015cb03f21f175cd42345e039a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/9c9623bf2f455015cb03f21f175cd42345e039a0", + "reference": "9c9623bf2f455015cb03f21f175cd42345e039a0", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.4.5", + "php": ">=8.1", + "spomky-labs/base64url": "^2.0.4", + "web-token/jwt-library": "^3.3.0|^4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.68.3", + "phpstan/phpstan": "^1.10.57", + "phpunit/phpunit": "^10.5.9" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.2" + }, + "time": "2025-01-29T17:44:07+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -10680,6 +10745,71 @@ ], "time": "2025-01-13T13:04:43+00:00" }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, { "name": "spomky-labs/otphp", "version": "v10.0.3", @@ -10755,6 +10885,115 @@ }, "time": "2022-03-17T08:00:35+00:00" }, + { + "name": "spomky-labs/pki-framework", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/eced5b5ce70518b983ff2be486e902bbd15135ae", + "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-06-13T08:35:04+00:00" + }, { "name": "stevenmaguire/oauth2-keycloak", "version": "5.1.0", @@ -17577,6 +17816,96 @@ }, "time": "2018-11-23T01:37:27+00:00" }, + { + "name": "web-token/jwt-library", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "650108fa2cdd6cbaaead0dc0ab5302e178b23b0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/650108fa2cdd6cbaaead0dc0ab5302e178b23b0a", + "reference": "650108fa2cdd6cbaaead0dc0ab5302e178b23b0a", + "shasum": "" + }, + "require": { + "brick/math": "^0.12 || ^0.13", + "ext-json": "*", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-03-12T11:25:35+00:00" + }, { "name": "webit/eval-math", "version": "1.0.2", @@ -22168,7 +22497,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -22193,7 +22522,7 @@ "ext-zip": "*", "ext-zlib": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "8.2" }, diff --git a/public/img/pwa-icons/icon-192.png b/public/img/pwa-icons/icon-192.png new file mode 100644 index 00000000000..9c4cafb9a74 Binary files /dev/null and b/public/img/pwa-icons/icon-192.png differ diff --git a/public/img/pwa-icons/icon-512.png b/public/img/pwa-icons/icon-512.png new file mode 100644 index 00000000000..3701f0dc2fe Binary files /dev/null and b/public/img/pwa-icons/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000000..2f7c0f0a09b --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Chamilo LMS", + "short_name": "Chamilo", + "start_url": "/", + "display": "standalone", + "theme_color": "#1b4fa0", + "background_color": "#ffffff", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/img/pwa-icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/img/pwa-icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 00000000000..0e2945ab127 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,66 @@ +/** + * Chamilo Service Worker + * Handles: + * - Offline caching + * - Push notifications + */ + +// PWA: Cache basic files on install +self.addEventListener('install', (event) => { + console.log('[Service Worker] Install event'); + + event.waitUntil( + caches.open('chamilo-cache-v1').then((cache) => { + return cache.addAll([ + '/', + '/manifest.json', + '/img/pwa-icons/icon-192.png', + '/img/pwa-icons/icon-512.png', + ]); + }) + ); +}); + +//PWA: Serve from cache if available +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (url.pathname.startsWith('/r/')) { + return; + } + + event.respondWith( + caches.match(event.request).then((cachedResponse) => { + return cachedResponse || fetch(event.request); + }) + ); +}); + +// PUSH NOTIFICATIONS +self.addEventListener('push', function (event) { + console.log('[Service Worker] Push received.'); + + let data = {}; + if (event.data) { + data = event.data.json(); + } + + const options = { + body: data.message || 'No message payload', + icon: '/img/pwa-icons/icon-192.png', + data: { + url: data.url || '/', + }, + }; + + event.waitUntil( + self.registration.showNotification(data.title || 'Chamilo', options) + ); +}); + +self.addEventListener('notificationclick', function (event) { + event.notification.close(); + const url = event.notification.data.url || '/'; + event.waitUntil( + clients.openWindow(url) + ); +}); diff --git a/src/CoreBundle/Controller/PlatformConfigurationController.php b/src/CoreBundle/Controller/PlatformConfigurationController.php index 4d50e60cbb1..692468da29d 100644 --- a/src/CoreBundle/Controller/PlatformConfigurationController.php +++ b/src/CoreBundle/Controller/PlatformConfigurationController.php @@ -116,6 +116,7 @@ public function list(SettingsManager $settingsManager): Response 'registration.redirect_after_login', 'platform.show_tabs_per_role', 'platform.session_admin_user_subscription_search_extra_field_to_search', + 'platform.push_notification_settings', ]; $user = $this->userHelper->getCurrent(); diff --git a/src/CoreBundle/Controller/PushNotificationController.php b/src/CoreBundle/Controller/PushNotificationController.php new file mode 100644 index 00000000000..e8c6bff1819 --- /dev/null +++ b/src/CoreBundle/Controller/PushNotificationController.php @@ -0,0 +1,202 @@ +userHelper->getCurrent(); + + if (!$currentUser) { + return new JsonResponse([ + 'error' => $this->translator->trans('Current user not found.'), + ], 403); + } + + // Check permission (example: only admins) + if (!$currentUser->isAdmin()) { + return new JsonResponse([ + 'error' => $this->translator->trans('You do not have permission to send notifications to other users.'), + ], 403); + } + + // Find target user + $user = $userRepository->find($userId); + + if (!$user) { + return new JsonResponse([ + 'error' => $this->translator->trans("This user doesn't exist"), + ], 404); + } + + $settings = $this->settingsManager->getSetting('platform.push_notification_settings', true); + + if (empty($settings)) { + return new JsonResponse([ + 'error' => $this->translator->trans('No push notification setting configured.'), + ], 500); + } + + $decoded = json_decode($settings, true); + + $vapidPublicKey = $decoded['vapid_public_key'] ?? null; + $vapidPrivateKey = $decoded['vapid_private_key'] ?? null; + + if (!$vapidPublicKey || !$vapidPrivateKey) { + return new JsonResponse([ + 'error' => $this->translator->trans('VAPID keys are missing in the configuration.'), + ], 500); + } + + $subscriptions = $this->subscriptionRepository->findByUser($user); + + if (empty($subscriptions)) { + return new JsonResponse([ + 'error' => $this->translator->trans('No push subscriptions found for this user.'), + ], 404); + } + + $webPush = new WebPush([ + 'VAPID' => [ + 'subject' => 'mailto:' . $user->getEmail(), + 'publicKey' => $vapidPublicKey, + 'privateKey' => $vapidPrivateKey, + ], + ]); + + $successes = []; + $failures = []; + + foreach ($subscriptions as $subEntity) { + try { + $subscription = Subscription::create([ + 'endpoint' => $subEntity->getEndpoint(), + 'publicKey' => $subEntity->getPublicKey(), + 'authToken' => $subEntity->getAuthToken(), + 'contentEncoding' => $subEntity->getContentEncoding() ?? 'aesgcm', + ]); + + $payload = json_encode([ + 'title' => $this->translator->trans('Push notification test'), + 'message' => $this->translator->trans("This is a test push notification from this platform to the user's browser or app."), + 'url' => '/account/edit' + ]); + + $report = $webPush->sendOneNotification( + $subscription, + $payload + ); + + if ($report->isSuccess()) { + $successes[] = $subEntity->getEndpoint(); + } else { + $failures[] = [ + 'endpoint' => $subEntity->getEndpoint(), + 'reason' => $report->getReason(), + 'statusCode' => $report->getResponse()->getStatusCode(), + ]; + } + } catch (\Throwable $e) { + $failures[] = [ + 'endpoint' => $subEntity->getEndpoint(), + 'reason' => $e->getMessage(), + ]; + } + } + + return new JsonResponse([ + 'message' => $this->translator->trans('Push notifications have been processed.'), + 'success' => $successes, + 'failures' => $failures, + ]); + } + + #[Route('/send-gotify', name: '_core_push_notification_send_gotify')] + public function sendGotify(): JsonResponse + { + $user = $this->userHelper->getCurrent(); + + if (!$user) { + return new JsonResponse([ + 'error' => $this->translator->trans('User not found.'), + ], 403); + } + + $settings = $this->settingsManager->getSetting('platform.push_notification_settings', true); + + if (empty($settings)) { + return new JsonResponse([ + 'error' => $this->translator->trans('No push notification settings configured.'), + ], 500); + } + + $decoded = json_decode($settings, true); + + $gotifyUrl = $decoded['gotify_url'] ?? null; + $gotifyToken = $decoded['gotify_token'] ?? null; + + if (!$gotifyUrl || !$gotifyToken) { + return new JsonResponse([ + 'error' => $this->translator->trans('Gotify configuration is missing.'), + ], 500); + } + + // Prepare the payload for Gotify + $payload = [ + 'title' => $user->getEmail(), + 'message' => $this->translator->trans('This is a test notification sent to Gotify from this platform.'), + 'priority' => 5, + ]; + + $client = HttpClient::create(); + + try { + $response = $client->request('POST', rtrim($gotifyUrl, '/') . '/message', [ + 'headers' => [ + 'X-Gotify-Key' => $gotifyToken, + ], + 'json' => $payload, + ]); + + $statusCode = $response->getStatusCode(); + $content = $response->toArray(false); + + return new JsonResponse([ + 'message' => $this->translator->trans('Gotify notification has been sent.'), + 'status' => $statusCode, + 'response' => $content, + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'error' => $this->translator->trans('Error sending notification to Gotify: ') . $e->getMessage(), + ], 500); + } + } +} diff --git a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php index 85fc882c526..03e0e5dc824 100644 --- a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php +++ b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php @@ -1036,6 +1036,11 @@ public static function getExistingSettings(): array ], ], 'platform' => [ + [ + 'name' => 'push_notification_settings', + 'title' => 'Push notification settings (JSON)', + 'comment' => 'JSON configuration for Push notifications integration. Example: {"gotify_url":"http://localhost:8080","gotify_token":"yourtoken","enabled":true}. Leave empty if you do not want to use push notifications.', + ], [ 'name' => 'donotlistcampus', 'title' => 'Do not list this campus on chamilo.org', diff --git a/src/CoreBundle/Entity/PushSubscription.php b/src/CoreBundle/Entity/PushSubscription.php new file mode 100644 index 00000000000..1a6ad228a78 --- /dev/null +++ b/src/CoreBundle/Entity/PushSubscription.php @@ -0,0 +1,193 @@ + ['pushsubscription:read']], + denormalizationContext: ['groups' => ['pushsubscription:write']], + paginationClientEnabled: false +)] +#[ApiFilter(SearchFilter::class, properties: [ + 'endpoint' => 'exact', +])] +#[ApiFilter(NumericFilter::class, properties: [ + 'user.id', +])] +#[ORM\Table(name: 'push_subscription')] +#[ORM\Index(columns: ['user_id'], name: 'idx_push_subscription_user')] +#[ORM\Entity(repositoryClass: PushSubscriptionRepository::class)] +class PushSubscription +{ + #[Groups(['pushsubscription:read'])] + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[Groups(['pushsubscription:read', 'pushsubscription:write'])] + #[Assert\NotBlank] + #[ORM\Column(name: 'endpoint', type: 'text')] + private string $endpoint; + + #[Groups(['pushsubscription:read', 'pushsubscription:write'])] + #[Assert\NotBlank] + #[ORM\Column(name: 'public_key', type: 'text')] + private string $publicKey; + + #[Groups(['pushsubscription:read', 'pushsubscription:write'])] + #[Assert\NotBlank] + #[ORM\Column(name: 'auth_token', type: 'text')] + private string $authToken; + + #[Groups(['pushsubscription:read', 'pushsubscription:write'])] + #[ORM\Column(name: 'content_encoding', type: 'string', length: 20, nullable: true, options: ['default' => 'aesgcm'])] + private ?string $contentEncoding = 'aesgcm'; + + #[Groups(['pushsubscription:read', 'pushsubscription:write'])] + #[ORM\Column(name: 'user_agent', type: 'text', nullable: true)] + private ?string $userAgent = null; + + #[Groups(['pushsubscription:read'])] + #[ORM\Column(name: 'created_at', type: 'datetime')] + private DateTime $createdAt; + + #[Groups(['pushsubscription:read'])] + #[ORM\Column(name: 'updated_at', type: 'datetime')] + private DateTime $updatedAt; + + #[Groups(['pushsubscription:read', 'pushsubscription:write'])] + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + private ?User $user = null; + + public function __construct() + { + $this->createdAt = new DateTime(); + $this->updatedAt = new DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + public function setEndpoint(string $endpoint): self + { + $this->endpoint = $endpoint; + + return $this; + } + + public function getPublicKey(): string + { + return $this->publicKey; + } + + public function setPublicKey(string $publicKey): self + { + $this->publicKey = $publicKey; + + return $this; + } + + public function getAuthToken(): string + { + return $this->authToken; + } + + public function setAuthToken(string $authToken): self + { + $this->authToken = $authToken; + + return $this; + } + + public function getContentEncoding(): ?string + { + return $this->contentEncoding; + } + + public function setContentEncoding(?string $contentEncoding): self + { + $this->contentEncoding = $contentEncoding; + + return $this; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function setUserAgent(?string $userAgent): self + { + $this->userAgent = $userAgent; + + return $this; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function setCreatedAt(DateTime $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): DateTime + { + return $this->updatedAt; + } + + public function setUpdatedAt(DateTime $updatedAt): self + { + $this->updatedAt = $updatedAt; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): self + { + $this->user = $user; + + return $this; + } +} diff --git a/src/CoreBundle/Helpers/PushNotificationHelper.php b/src/CoreBundle/Helpers/PushNotificationHelper.php new file mode 100644 index 00000000000..151d0de49bd --- /dev/null +++ b/src/CoreBundle/Helpers/PushNotificationHelper.php @@ -0,0 +1,64 @@ +settingsManager->getSetting('platform.push_notification_settings'); + + if (empty($settings)) { + return; + } + + $decoded = json_decode($settings, true); + + $gotifyUrl = $decoded['gotify_url'] ?? null; + $gotifyToken = $decoded['gotify_token'] ?? null; + $enabled = $decoded['enabled'] ?? false; + + if (!$enabled || empty($gotifyUrl) || empty($gotifyToken)) { + return; + } + + $subscriptions = $this->subscriptionRepository->findByUser($user); + + if (empty($subscriptions)) { + return; + } + + $client = HttpClient::create(); + + $client->request('POST', $gotifyUrl.'/message', [ + 'headers' => [ + 'X-Gotify-Key' => $gotifyToken, + ], + 'json' => [ + 'title' => $title, + 'message' => $message, + 'priority' => 5, + 'extras' => [ + 'client::notification' => [ + 'click' => [ + 'url' => $url, + ], + ], + ], + ], + ]); + } +} diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20250630093000.php b/src/CoreBundle/Migrations/Schema/V200/Version20250630093000.php new file mode 100644 index 00000000000..f1ad32d244b --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V200/Version20250630093000.php @@ -0,0 +1,50 @@ +addSql( + "CREATE TABLE push_subscription ( + id INT AUTO_INCREMENT NOT NULL, + user_id INT DEFAULT NULL, + endpoint LONGTEXT NOT NULL, + public_key LONGTEXT NOT NULL, + auth_token LONGTEXT NOT NULL, + content_encoding VARCHAR(20) DEFAULT 'aesgcm', + user_agent LONGTEXT DEFAULT NULL, + created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + updated_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + INDEX idx_push_subscription_user (user_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC;" + ); + + $this->addSql( + "ALTER TABLE push_subscription + ADD CONSTRAINT FK_562830F3A76ED395 + FOREIGN KEY (user_id) + REFERENCES user (id) + ON DELETE CASCADE;" + ); + } + + public function down(Schema $schema): void + { + $this->addSql("DROP TABLE push_subscription;"); + } +} diff --git a/src/CoreBundle/Repository/PushSubscriptionRepository.php b/src/CoreBundle/Repository/PushSubscriptionRepository.php new file mode 100644 index 00000000000..67be1f5a611 --- /dev/null +++ b/src/CoreBundle/Repository/PushSubscriptionRepository.php @@ -0,0 +1,73 @@ +createQueryBuilder('p') + ->where('p.user = :user') + ->setParameter('user', $user) + ->orderBy('p.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * Find a subscription by its endpoint. + */ + public function findOneByEndpoint(string $endpoint): ?PushSubscription + { + return $this->createQueryBuilder('p') + ->where('p.endpoint = :endpoint') + ->setParameter('endpoint', $endpoint) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Remove all subscriptions for a user (e.g. on logout). + */ + public function removeAllByUser(User $user): void + { + $qb = $this->createQueryBuilder('p'); + $qb->delete() + ->where('p.user = :user') + ->setParameter('user', $user) + ->getQuery() + ->execute(); + } + + /** + * Remove a subscription by endpoint. + */ + public function removeByEndpoint(string $endpoint): void + { + $qb = $this->createQueryBuilder('p'); + $qb->delete() + ->where('p.endpoint = :endpoint') + ->setParameter('endpoint', $endpoint) + ->getQuery() + ->execute(); + } +} diff --git a/src/CoreBundle/Resources/views/Layout/head.html.twig b/src/CoreBundle/Resources/views/Layout/head.html.twig index 90be7f71ce4..b31a84d5f95 100644 --- a/src/CoreBundle/Resources/views/Layout/head.html.twig +++ b/src/CoreBundle/Resources/views/Layout/head.html.twig @@ -60,4 +60,7 @@ {% endautoescape %} {% endblock %} {% endblock %} + + + diff --git a/src/CoreBundle/Settings/PlatformSettingsSchema.php b/src/CoreBundle/Settings/PlatformSettingsSchema.php index 01179df9b7f..adac9f5fa49 100644 --- a/src/CoreBundle/Settings/PlatformSettingsSchema.php +++ b/src/CoreBundle/Settings/PlatformSettingsSchema.php @@ -96,6 +96,7 @@ public function buildSettings(AbstractSettingsBuilder $builder): void 'notification_event' => 'false', 'show_tabs_per_role' => '{}', 'session_admin_user_subscription_search_extra_field_to_search' => '', + 'push_notification_settings' => '', ] ) ->setTransformer( @@ -304,6 +305,20 @@ public function buildForm(FormBuilderInterface $builder): void 'help' => 'User extra field key to use when searching and naming sessions from /admin-dashboard/register.', ] ) + ->add( + 'push_notification_settings', + TextareaType::class, + [ + 'help_html' => true, + 'help' => '
{
+    "gotify_url": "http://localhost:8080",
+    "gotify_token": "A0yWWfe_8YRLv_B",
+    "enabled": true,
+    "vapid_public_key": "BNg54MTyDZSdyFq99EmppT606jKVDS5o7jGVxMLW3Qir937A98sxtrK4VMt1ddNlK93MUenK0kM3aiAMu9HRcjQ=",
+    "vapid_private_key": "UgS5-xSneOcSyNJVq4c9wmEGaCoE1Y8oh-7ZGXPgs8o"
+}
', + ] + ) ; $this->updateFormFieldsFromSettingsInfo($builder);