From 01050aefb352a22831fce3b42030e1f89d423717 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Tue, 1 Jul 2025 13:00:50 -0500 Subject: [PATCH 01/13] User: Use push functionality in webbrowsers - refs #3255 --- .../vue/components/social/UserProfileCard.vue | 134 ++++++- assets/vue/composables/usePushSubscription.js | 223 ++++++++++++ assets/vue/utils/pushUtils.js | 23 ++ composer.json | 1 + composer.lock | 335 +++++++++++++++++- public/service-worker.js | 26 ++ .../PlatformConfigurationController.php | 1 + .../Controller/PushNotificationController.php | 202 +++++++++++ .../DataFixtures/SettingsCurrentFixtures.php | 5 + src/CoreBundle/Entity/PushSubscription.php | 193 ++++++++++ .../Helpers/PushNotificationHelper.php | 64 ++++ .../Schema/V200/Version20250630093000.php | 50 +++ .../Repository/PushSubscriptionRepository.php | 73 ++++ .../Settings/PlatformSettingsSchema.php | 15 + 14 files changed, 1333 insertions(+), 12 deletions(-) create mode 100644 assets/vue/composables/usePushSubscription.js create mode 100644 assets/vue/utils/pushUtils.js create mode 100644 public/service-worker.js create mode 100644 src/CoreBundle/Controller/PushNotificationController.php create mode 100644 src/CoreBundle/Entity/PushSubscription.php create mode 100644 src/CoreBundle/Helpers/PushNotificationHelper.php create mode 100644 src/CoreBundle/Migrations/Schema/V200/Version20250630093000.php create mode 100644 src/CoreBundle/Repository/PushSubscriptionRepository.php diff --git a/assets/vue/components/social/UserProfileCard.vue b/assets/vue/components/social/UserProfileCard.vue index d9decef55dc..a5febbc8432 100644 --- a/assets/vue/components/social/UserProfileCard.vue +++ b/assets/vue/components/social/UserProfileCard.vue @@ -96,6 +96,73 @@ +
+

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

+ +
+ + + +
+
+ diff --git a/assets/vue/composables/usePushSubscription.js b/assets/vue/composables/usePushSubscription.js new file mode 100644 index 00000000000..36f8ab70a82 --- /dev/null +++ b/assets/vue/composables/usePushSubscription.js @@ -0,0 +1,223 @@ +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(false) + 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) { + console.error("Error checking backend push subscription:", e) + isSubscribed.value = false + subscriptionInfo.value = null + } finally { + 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/service-worker.js b/public/service-worker.js new file mode 100644 index 00000000000..899f61f9225 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,26 @@ +self.addEventListener('push', function (event) { + let data = {}; + if (event.data) { + data = event.data.json(); + } + + const options = { + body: data.message || 'No message payload', + icon: '/img/logo.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 a20dc5cf26e..6f9226135bd 100644 --- a/src/CoreBundle/Controller/PlatformConfigurationController.php +++ b/src/CoreBundle/Controller/PlatformConfigurationController.php @@ -113,6 +113,7 @@ public function list(SettingsManager $settingsManager): Response 'session.session_automatic_creation_user_id', 'session.session_list_view_remaining_days', 'profile.use_users_timezone', + '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..13e39da1438 --- /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('The user does not exist.'), + ], 404); + } + + $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); + + $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('Chamilo Notification'), + 'message' => $this->translator->trans('This is a test push notification from Chamilo.'), + '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: 'chamilo_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 Chamilo.'), + '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 3608e829c8f..2d96c3dcad6 100644 --- a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php +++ b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php @@ -1056,6 +1056,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/Settings/PlatformSettingsSchema.php b/src/CoreBundle/Settings/PlatformSettingsSchema.php index fcb62f64d0a..3e8b781d9b2 100644 --- a/src/CoreBundle/Settings/PlatformSettingsSchema.php +++ b/src/CoreBundle/Settings/PlatformSettingsSchema.php @@ -93,6 +93,7 @@ public function buildSettings(AbstractSettingsBuilder $builder): void 'redirect_index_to_url_for_logged_users' => '', 'default_menu_entry_for_course_or_session' => 'my_courses', 'notification_event' => 'false', + 'push_notification_settings' => '', ] ) ->setTransformer( @@ -282,6 +283,20 @@ public function buildForm(FormBuilderInterface $builder): void ] ) ->add('notification_event', YesNoType::class) + ->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); From a5b752a44c475d35a45cdb2e2b564ca8d86088fc Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Wed, 2 Jul 2025 02:52:56 -0500 Subject: [PATCH 02/13] Internal: Implement PWA support with manifest, icons, and service worker - refs #4961 --- assets/vue/App.vue | 11 ++++++ public/img/pwa-icons/icon-192.png | Bin 0 -> 8740 bytes public/img/pwa-icons/icon-512.png | Bin 0 -> 24789 bytes public/manifest.json | 21 ++++++++++ public/service-worker.js | 37 +++++++++++++++++- .../Resources/views/Layout/head.html.twig | 3 ++ 6 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 public/img/pwa-icons/icon-192.png create mode 100644 public/img/pwa-icons/icon-512.png create mode 100644 public/manifest.json diff --git a/assets/vue/App.vue b/assets/vue/App.vue index d76b957d098..3eb9fa2ebdd 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/public/img/pwa-icons/icon-192.png b/public/img/pwa-icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..9c4cafb9a74b50042987c1bb07d3030a330eda77 GIT binary patch literal 8740 zcmY*fWmr^C7r(pg0t*W)-QC?GA>ATKE46?usgyLr0wN(GsdR^QO2?8a(kRlbASK;` z2rvJS@60nd&dfP;@64Q;-#Ife1`jof2MK?SE07W>f zsv2mks1C3FR=*O%ppB8PIv=qw!t=8MuALrwFF+L)8}*W#-x;jd|Z9uY%v^5U!<`Md>e4p!yA-Y(`9*$6&?<3|r@qtd00 zWRFfupLr~ECp5%Xk>!!=r@v5M)vV!X>hTon5I7w9knwelR5NDKi<;kRgs)U1nSG zB|%#S@>lvNT#3&$%-19#ey(YyTwTRl1o&MtW1n~s%g`r8pDyqFX3xM7!MHW2386)> zk>KRzEvI^RB$B?hb>C^2$)gPiu0Y$s+hLekYQP6Na6Hw6A`hvyEY1R557tgWpC6fJ z&||R(8h)em%<+z(j-bC64?237t24d2ySffP-t`Kuovx-DaQk1(?7~+1P)6&nh6;SiSk?p^x;OIT>Ui zlG_denF4tlLs(l2w^&@#X|?c3VSK+a>>!_9oD5InWNi2eLoZaD8w<&kjU@;t2t$8} z-iByAHS;td5J*fc{yP&8%E6=c&ibG$=i9~KBfRYepUR4!gIw?5xw256{|il|iJ9Ru z(B+uuSl^t&onr37oH~u5Vz*B!1NdL0-hIo3Sj4cfT++<8_nfFZtnh(&UNI6g$Vkf; z?$4fuZwGQ&609;IW$DqQngk4hJ!iDd{hhvH{WO5$K|H&S-!==|?;HA8c$h6R;~mfw zZi&6Hv7=AA|7Azzoe#`qG=YJaDx0dks^HMw#l~YOCjgzsZrz;!V;RH+JlhAz;y$&X zRs>tZlzJ(`_8z%@dIqs9-h9?8@w>{JlDT=KjWX8S7JFqbw_GNExx<{G(%mOVS^&vM zRzU&7_%_iT8`PeR=^eL7>cIhprc$BjYSGSJ^>EdktZ}hpKeo@-Isn1zUb~b%xKB^I z+uh3$seu#aYhScM`JEUV`R0UmTs*A9f29h7E>un6-{>?DreFgR8VJn>ZoR?X6BBN7 z;+@R-Iv|y>c;X#2KO70|x8;BdRxd&un#Bqc`-ASq1MV@RXxI@@sKmoQbe}7jN5DVI z8q-@Bs3ST&o8M`L$|6H=$K>H=aXd(li(>U;1+q10aA|1ZLyS`qn@tk(g2Rd0UmsCZ zt&#KK(7$lk9)^nDyA)1S;-w)B3p{Q?YXNV)7)+18kDuQ4Ka><$|OKJC>3UjOM#A=Q7y8JKcQ z@ign1^EVbhHJH)`b0BfmRO}DX3z0hdBty&>Kh`NVqoN+D0eFIWUO5iWz7E!I_ny?h zLXQwFr~}9U}GSS~LN_p8WgwIxf}-t|3HHV3Z8SBXV;CuZ~1ZFwjC_xBZ<%)k@W zs2bIbQKN~TQAls$S(!?fFg|Km?8s7GyH7EGn9hOlv>B*g{wJCP2%L3vjKKRFG>H63 zOCkBvTb#kFao@@D-yeYkC?0-<#Fd z!ds&>8fUWY9(!EWo@DqK=x>8kt*7f?yvWCGigW3I`&3V{qyll&ym69iuK7CkAwjN*#*`D7jz*GJ2x0_*ZZTJCn19+q z?_4z}Ze4|RZz;KQB%+lRL!9DUPfsX7ygtPv9x~-AzpewI$a~+&2voH)VOmhT%sA4K zFPu}i(bo3m@v$<65TpiaC;9-g76?R*%qfp-N^rdTJU(BZlKA$;LC6Ok7^aHNAGU%& z#FLV_tpyVx8hjm?{7w&OnsA+Ms((j@;br@yBcg#LvXOw|FU!&#ZMm8yIm1#E>baW18`q#YAATT(JRr4dPUI zLCz92KaNyHA_eqgl`@vr-ZGnl@!8F(ZF$jn4`x3;7dW(=uqq(r7XwCY^QBtrg>&ER zJWh&~g1G2KOqOCv4Gv!(pfl99nuP}Fd!M(s>F+|}&lsX#i%l>pnJEVP!8z{Z{aS4d zWLk)p!gYCuGgXS^O^k_k{cc|?gGX&tdc!$JIgciMF^{(HV+W`*sw}8o6tNH0rSFF6 z1G+cf9?a+;dXkf`7ATiefm2dtu42Ft=07gs33!wj-pxrNY~~Q@L6!{Ij^0!WaqM#) z;3^hNn_VaJ9C}y+6Tn#iCy8A^Inu+P0$oeAhldwRYpe{z$Q=s~k+OO)#sv`^P&H$F z5Jvc5>G>(-JtAstq(Z(cm!>EOqyy*I-&)M|;Dq)~)-QDnkEI{b$yXHs8lOciI)nV3LEm;() zT1w2xtq?V8oCXplg(q8<-Nut+}s0*^Z5>?9}XX*c?~CyTo>g24w1!B!8rK{w$n%l3x^X3hPz z4)#H>PaXrIr$6}>b;3kTP8jIZfH<;V3Yq9bIn0bTMxeK2(OW_SS_l^QK03vQQG!88)d=y5TqqCnEU&ChlAM>2O&`L1Y0V^tM=C~ z@YjUgk0gLU9P{|6nEC_%hJ)qkk)e0Sd$eOjz-!!z5Xz+1QW@7i$3VCS&u|{`j!GwM z)fNsm)DyK-@DD)6h+T6T94}0J3olMK`ZA&&`=hS`Bi_!N7Ew!l@iNzS=S z^a^_dB4Zjm@L?mPx1hcRp=Ak)DtEWr%&w2Q35LVCAb}ppcJC%$Cfl7}#>s}2oP zKiCizVRz?o_9DmpvsedUd&*5-NR1mMATbkv-qcvz|9fukg5V3Q3y?R>>Z$^)h{K)^ zqzx3(-@nSG-NElPO#UMxx$I*;+1<;iHBSF^ptj#~=c=HO1j)2-MxKmtjWs+cc6uxv;^Xc`7vUq=0zI+zEO z-2HJ&_*T4>`i;$D&EW6cssOhYJkRTu@U}dD?vFZVAeoC(eGcB4EA5-1I->kEfnR(i zj5dnfeJ>@e6Rv5;DnV7ah)L8-#UV>B4l)rb|EDEYtGlUl?J#8T*q`VzijS0MJ<((J&6bC+w?L?t#Y*CjYRbBmzTJ`h*o*u`vnX4yggCWYh&Ncs76X;Lr) z<{*6Ti^iivtDZ3*pLg!P?d$osbWCU|@}mx96>aFFFYnb@6=>L4;2Dn)CJxpAX5XlT zBb5XRCrxUqQuS3`K)+oEj1z5txo^>vIMnD@4X@;QVA`@w3BFWL5W8|&8rW5MyH!^E z=u%wzGzbXXLa;N5PtcuKFnSPNbeqEo=(Zc8dxPNjJ>^?Y*B;72*pGWG<6)tLdjG1> z9Q&az#D)gYL<_gKq@tDy*u69&pC+GIG?kzmr2}fu^V3BT%T*O??xTqVX&dw8y;f-s z@aF~R9e9J{ zYT1a5lD;fUm-ieJiF}Wtcq~S^LWCaB$G%~eb7lEps3hh9? zB45%jJIK2~{~HQX7opSp{FxDiY*;M_dye=igyw~SkwN@9jk=LoqJWNFavW!ln_eg> zq!a&RUZ2w`os9Tx0|Fm_Rr^m>0X@kr)B6|64lmR;?*V5f*Wyf>k)h1SAc4Pu6K(jl z3KcgP6>QI+^{bIF-8<8yF!!F)h%}xFFvUhb-!~v?`zt)>sGGY|drTSo*NQTnJFNRW zZqW~K^oj3t4~SCr%&lTSh2;vtW`N_>;+q^y8Zafz~f7H94a)LVL}6? zH)OoV?JoTAp-^32v4FbNbRm7?^+ay2v;qrA3moOV+`Hy4TQ=ue*)M zV;4HWz_q7q9;xzj%wq1(P72KTSXJA%HOP<|eh@>g(}mQ;@c75H<;z=mZfWbZi-)Ev zuKNl2gCEh>c=ZBL;FBicZiLZ*Ir*2HO4HMsPT6n)!&NKY==#>QvHno5)2oXcs=S+5 zPhW-p3yGW-9pDE680jO<3m}^)y@etsR3GGae(db|epRpdK1gG_Y_ZDY@mTEG z8=W!Nn%5K9?J@5~sa+7FTxQw@%QZ1_H|uP^YzCHDN-k=202`@mIU+*eA(;G;h&Tf=LzS$jL8Qr(@=DO(Gx6-ZBs+JIJFj~LVn;A7kt%BVCW6jCjWc)Y~ zsS)B1Yh$!EByNabgD9y3wTYQ$qf=$K*o$&imy#mT`QtGMs&sGq&(| zX2ZCz!mYtGk5v^YT+7Cenxk1ICo7mQf$h(_r!V=|J=FflfFtP#LXcJKa*2X-TP0{& zFJFOdm#t8*M&MI|0g+)w5D-4F_ER_H2ByXgo>eBhO)6aePEN)FwSz^o#b_Z`X`(p{ zC?bOL*BjyT1HX`~Jvdtw**^I9rqjfaY*3k9Qo`t0r<|&Y;(n?)aM+6Qn2t~2zsP&u zYIZ)?I=D6N8x@Titr4%J%rS-lb_$axVMhQM!SYA5f-6F=qE7H-#iw0i+W~7`H#(@& zrgl+MCEZP)V!uPRTf|xn4Q}5K=puF0OuIvmFyFRvQL?)44SR^7r|-Uqdr#f^<4>V9 z@~}~ExpUd)l~&esuuOh_2l;4bXs~R|C!CEHvKW5zsZlEJ(#ED|viQx8GKx?Sb$&WS zO3VT7EdWwm8TAd}^yCf` zR&KmtUf>~f++6;gY)Us9$qae{I~woMBY4Q4vpGBaz)9!)^jcaeIEXNQ?+0YaEo+nK zp}$NIwe$Q}AiyRzv3`Kc=;iIZk@D|%4vPNym|{g*|G&5F6C8{hb2eI4&TjnJ^4zwW z?jA9?GHJ!wbYNXzrMX(;ebvm%{0OH7@AnUS44ZLaGry z(?+$oaB#1e1XA-|s35v_RRw(FN?J>v-n>$A0Kw@$O8nX$$IR2{59BOtBvdX)`7L_l z&Ria@TMGzk1Kk&EGfV99t1%lwv;XB-yf_<$cdn~rx(=WA@|r#TZEfZ|>;vrMd5pa2 ze!;@JLMy)%kU)kO4Lc~&NR@HKp2$}G-79q*%K77P>>Np=mn}TY2;KMzoL_m~YU40l zFE-Al72LHI=VZq|WIDy#pLiVpz%Swcwnke6<1K>fEhAdm(}cT)QvA-k_9qZ$62Ug- zwO1dPGCoduo3l*a2EM=BhT@O+S})#*PI>^{rFgRv&RrdYm15#0{M_r0*d9VBEW3%c z`zmqDgcB4lqiS~0j*dOHtycPt1mH{R&xn4$sRaph4|nzG#NTU>uzmC z47`+G99G4!z4^XWE5prMTk@vpASo-CElj;OjJvm}c2@7MG{N^MzP-woyR0)<_Oxe1%wXP3vGbQtG5 zMPz3QDULKji-lgbN_iLKVY*@(kA20wXUa+RkHw6dy7SIPGa?;J14)N~f2pZ2*tPFP z{5JBT-&rxm_taaQ|G3`Ws(5G3+@P`Voi0f+56paH8%wgY-XZhS{>sc&woG$h?Y`Oa zAjo*AsuH+N0aheZ`Kxiiro3_ZBrAw@gNSVUWJt{Oru@j*NAK~|I$!6H_o%lzSXB#$ zHRI9^?+5#rCmu(<_PHt@xQCy4-gL09U-xBPcc-eMmVY6Mp63$8GQ@btRL;N1ipNVofJFieY%jdSXb|h#+CS3bX^w$>A@t2YcGLBh}>tcNSld31GjTzt`)yR?7ewLP<7hWCl{zjW297-$mn?5>Sl zKI*WRSpDSunc?5bof@xaNB0pS6A~&4TBIG{%GG>uEo4atJ(98;a7sRAMOc+}(AqP7 zcm8)}q6bNv#y|DzIpY-hwnA-5P`wCdc&OvDk`NFKJ%QWp6N;fsObCvoaqkyvSf7#c zBo;mtQV-!oi!Wun*0cdk;}MC+OvHxY>o5-xBtrC}t5j7M7th)Ah9DEiZ(`YW0L8Vf zXi`yW$!HV4`qDq>Ai%&{>sxguhbA54eGmfWpY0j#Iw!#2{MfV zN;8|6_Xz8WdwF6jNQixS`DE<_5}|)N0hB-GHCM4PEqgDsDC@Fxm@N&uZveh&5rGnv zGkY|t2F7MRk4;qjiUViyNr(u5NoF1%T#hum$>lqb5``Am3V>{{!}57WZ2Kr)(q`}10ct)4-t1Klw$qv#p5X#FJkmbxJ&{+8EQTLQL2O{T!aUHRRi)r zZ(UGma(y&OqSvAE>pjr!Wi~f5-9!DGzu+>Q%1YGkfnlmd6kw;fd?2h9&A&!K=-AYx zXznue@@5h1v7W^BHIz{AKEQmE9bHcpM_bHsQIABAH3{&FhUK= zwPhiR^LD`0=W+!4x@SX zKUr$3`|1|WtvKg-pQFG{Jt8lWJyAJjT}yiYGK$1Xy6(^3f@eW}c%zoyp5i$s2Vl5g z*zY9bm4^^GE-}K}%hL8|dh1(7;wKD-bRYlfHW}A+Bt_Nh$-OL}=8Kfh`ooV2CKvDd zF(QeRby@XbatZ>D<`-Bbz?2&)E&^1okz4c~A^+%=-4(=*!p8+DzBiC6Jz0(8RP5QU zc+-3x{qDy}P?(cGVaG(Kw>L5WlgxfFrpWm%+o^70q~SE~ASKmJ{lff5)`cCN@Rg%A zS5dc;s+8L~DL+d_{DLLcp-$DwDGu<~6XR2yEov|%EfJ#(klPayri}6N%~SmjGtJ=% z9Vd~-2jW{CWsE{^YJX)Td>hA115{ub0Wg4G^YNJjx`|d5IMZ|bQ1l2h^K@NSfw^El zI)WCVqm!LmnT(EuVb3c3IWXww^nWf$Nbym)(c(wIm zIpWN`Zi0}xTT~6l-yP$&X#$8^4CDaNC6}ZDI(6+77yzu!)Q;mFY6BO%j)l(kk&*9x z=Id-5A4M$Pq}o%Xe_US&;<46?v9e3I{VIn!Nbx2Eo#fw`BeriTIQ4jd3Vq`|cz)kz zBn7H$6t%Muu+&bib?fk516MTzaJi-gg%Oa6AK%nCe`LA?n9tx$czy5C_BPm)>Jy`8 zew|DM06T|CMCPxA7gimG$!kIB=7dKcUqIx`bVMH+Wc)v)4w zdFG)N_YLq56Imoa0luKl9G2fSi#Qn0LqXf;`kPxPe_upRc^Lw=5Klc z7!4wN_mm`kWyY=MHDwQSZ&baUYjCxnOTpaSfjO@C9noX1D1>nSS`Q+7R`vpv)*`%V`Sr}Q3& zbv|9?j|Rb_WoGr~+Hhne?x*dxB_#=JOfd^V7_6!IwF~onlH_H2ro-LNls)!zuzqpu zI0%&)jfusnwLi>G-u>p|Vwwi@kwu_3Z1Q0Nl+iViulyjo=+dujI*8a9=Z`~ALY&9c z?vM&6+BEV%5A?aUT}(}Hn0Cc5-5uYT z_vifnfgi`=-1~e!UXSi8R#W{E0WK{r000C}9xFWu04Vq;6u`y=e;xUbT>tzrK^W7!s{@dB?JaaxHeC(JXPi)_d z?Mv++6*#-ib0k#7m6B$YX}tGRSbFr0hq29_zlnEmpy)&Y5Xqz1E)Oam(*f=Rl}tp! z*9`S@#d)jLLrS|cJ-h9I11gociv!X>FB1^)a9+hQZ1^i3t?+n>GAS;~s+#PBeWIq| zSQFgFVvCjcJ(%}8rjV7k%(LTu-ZVG4$xIBH<)W%en;nCXCvR4aA1=CXXkrHrw_np&Hm;%0n=}ViIRej@ATH3U!czTx!m5cr(G4X zH>@3Q^8^<9Ox~zW&v3(Ku2_-cErJE|6^|Ro$nl|58^zm(U)fLPlkXv;rj6Y5EZfT* zC_-^^9#6u2rl2^6wnK-PtOnv~yw$S-Ylp!tfkAEK_e4%5?S{^0MNVh&h)MpxzeY*0 zRppgjsywZFPSXAu(7y9t(n`e4cShxzmE+Xm&}z{0{x%wm(-(R|Sqh7+BYm8!NJeyZ zyN|5HER~3<3yq`7bygn7c-*wxmIi$VJtxhb(XG_t<4Qbzue+VF&=k(6fDb4sF=x)| z4!q=^{zLkuh=pdv3iyYe!-}jE^DA+L@KUtDO<2(3PtE?ERrcuabo>{(qzayIbLBe6 z!tz5fH({XvMRX;`h#mQX(ppyPZk28<1R1lgDYCli-A0VYg!5yK3@JyS5b7|lk9iI+ zC|OMY`T@u*Nzdr_4h?RXlaD^2km&mRsEIb|LBfXNxv+5-H4wvTyFEQ?TNJ7bpx1=F z*9Dl@Ke_1~3^{MX@9qjMm(-e08$78!hdz6=xL-tCT+cM_xJ!S6Z3!S){>)U?s>z8? zctVeCMFHTDTT8OiPGhWs{O;;b=9PH;118jOV7i)CZqF=e#AKGwZ$Y0IE(8!KZ?cEL zZ@A>4CGMI`G&onqs)-xJhPZb?_0RlOqZiXMs37;MXrpWS`PHNM62i#UDM1O5NFJ82 zkC~215B9ZbU5x+f`~4l?EG-NqqqkCLF_dGk1~4^dxB}|L~z82*o4~2L5 zrLa2+McDaORVr{l<3c9JOQHXJg|BO>R56Bg=ud5o9disQJtya$DD@r;iKtZU58h42 zwde4Dc~JJ&yVbW1$V6T2eHNq*fd$3g(JdK68p#A*YPv}5t!~83f`%jSj!8SlOE%*n?jU-y^X(WDy%0Koh5J9MEZu8t`R+_6vkHnb1(-|N zI?3-2YVS?dGSinDs@@@8h%R${_#G2vbjLu+hcnA3JD548O4tP zzvH16LqzxTJyr<~*jmX_I_5g(E!{Sqy7m#GkROX3gBob(bqcqrgiFV}_-Y?m7#Ql$ zbO9qC%~@fJj3b6nsiiM+Bt10YT7WPFAA3Lh78a&hLRtOy$ymkoPPLV1Q!0Q7YA2a~ zq3QJT)FlCr!siZ0V10xvgi>8(xw4nq37*6h)yD*=BbQ!~C8~w)Qqr*_mDW{l@#RRB+EA%TVIi!v}JCP`#|yh?eJpy6!LpNaHvkc}im;`WK_$THvobQofxm9wND)*{nz$Z1~U;$YD>Q z`?F|3hT6n6S%3RbY6^&SGF)5#9>By=PC0!0pUJ7w+-J8$?k_Q*UiGhv!`RWnbrmAL z!%1tZpO)J;^Cm^qHiJWsmf>gZIKBoFmNV%$9pSS_p*`dK51t4 zV7a7J36_S3V~)11;CycZ8!62lR|y|AD05s{ff&W1Dl7JK=#uUbuT0?vpTi1*aS_+p16Sx?q_hp#gSTn%yI-{1p zg0UK)%0j(|U!rN84qx}|Dd7WNBYn39%UvM7#GN+i3Hi(AdlOe0l8#`sJIZ^(jQ16* z?%W-RzJXbgF}(^OAJ{Pl(C~Ml)huYsF|JWJ_&lU-Fv5`kwG&>H!81ECICq-nN)re7tk;U~w|qB&Dk?RE++m zM676&_8~3ksbaQ;5L9L&j2gKwJ~A=Pw@5)=@`iuv;NcMZcIPZbgMbDcoD0dUC`8J* z{X1BmK`#h^CGegbp32KRPZ!hIcPmvhZq)n*MNz7f;^vqWPc54HBSY?Swf7Hz*fZr8 z`$q}ZFsESVxILu{K}~Fs(cqKP^E=dHD*G1l7zEAXYgM#^J!PRMBh?%Zz*q(8AT?e? z=j4JfoJMbOqCmG5pv!Co7RnpxhANuxdewJS7iFl}OQ{8l}q%Hfn%g zl5tN91wNHEWn3u}7tO54vQlvPHFR`m79SWKtxZdWvSR|LiHRvA-PF=>MCdqnBaHQZ zd3`R0DoOs$;I`AC7TdK}59orC{U3lG_()1B_Q&7?TsZH;F-$s4WC1DvEg1&#ayWr~ zDIH+1Bz+;yB?w8cz}ITe-(n}i4p|0%1P-yf1Gnf&)^nEpRAY)X+P{a~& zD?#~YV&w?%kWl`n zj03Z-XToDF>eLm*(^vvGX1d*l9i=ATfN%M!+&g0{v7me82pl$u3I=|EbyEefqci|d~OJ*BYpN~bJ2>q zBZ|z33^rb2_5(n-`bF8@ily4~@~%wCV*(E(qzt+!$UyQ;0ZlI(3a66nuZpt$D1g{z z{B4*~+8wi0NQPM^r+3fBP;OoUm`L_awk>NgmZLtU+Evw`@AVgAMzKNMo-~ctF6AL( zA*ixqL+H$AE{Y7~(=SMLiqUeV9{A*+Zmt!nx^5jfJp6)&4Y8@%<4^cbYsD$SSbz`k z|E<()=ax{B&8F?Gp5~0un1}<}@RCE!UC`?dEbskrS!m`MbsA`bM$agjIZ=+K5mrZQ z6DVRBaP0QnG0@1e48*9*?x8?6Zo)y6JS7u)jMPlsL5+sVfCr?`eLoxOgCP-%EtsxB z;6&nr;k0#zdISL#?6qAA%T_3wsWc+bYZR!*6idhPa19`>nKur3K(*m?rl6jT^ z49%ZUCHPtvyHPNcABy*JFi^?+|Jj>%d?nuUJ8>)z6Buz<$}DX!K)mGAMN5Jq|14;Q z>hq9xA_*{2O`FhB7OIA#o4s!vC);w zWenRAB}h%oAh96UOc`Pbz6UQ^EF#wg-D*6omqa6jI|M1pf{C1D02m)d!ZWc5Vd`%! z$nijpVEr_E;DUTK^!93&3`5>28p-m`7?TPz+2bIgBBveskGJ!MyN4Bs05X9Zx{7AV zJ0n2xDG+*STS)WU(h$0PZ!WHM-{o~YhBC;&-wAnuGQEL_EYW}tjNU3yNEV%d4R;}=ZfVqDv^N@> z61h-GY67uv22oQagJ%73>F+qd6MCIK7%?c@mStC>|Km#0*X3@jvkep(OzwrK6W=PR zWVFCU6g9v(#+-wlZ4l=P3G8y1Gz!s<53s7sjyr#AicSg_zH?ogB3OT>&L*E{676Me zp2&`O4px2_)eNW9|4KN;ch<-TW*&yv1~$A`m}qYn<817n^$B^3>iEHs7T0g8FbH*Qk1n6|MfJeh@lq`R zy>IgA3C8xWI3}utS)3hX-z98{@+6c%AaB`V*X)G67N1@9?K=fGyb@4t$c3|5q;b*w zdL4D7R?dz&p~s9LuQ`ezWE%`JkbV;@Ww*44DTMf`T9WZG%2cg~j zz)XRB2~(hh0f;lenfcC^6lvbm5js(;Zd4%8_wfqL%nAqOaEBsLo6t@GWLb)CO({X8 zN$`v@nm~X26sbS8vcQx?@E^OV?cPgMyBT7cDEf@2ByKY=bG<2CUR!C|cQ-!1<>1%N`F2DC)-%NUVy56r?J`pxt5e?PfjZ z$yO|Fr1XyhOp87;Ua+>)Tv0?l(=m#}0|I*;Ce@!g5tPQeUK5|e`gI?Ds#sgtIsl1I zCV(iPbyvCZ-`V0((jSI2b%TaN2LiF85-Vro8@s}+)b#3)WMHZ0k1@JjRDnDMwyZ_Byu)HRj{Ln| z9V$%xs6S^QckMEm2{tQl$Jtr}5g5&W8K>z&b481i)$7zt(CeQw#z=gyQ|+Ky`Wn(q z##(rD37Cx5>QneAfO*f9wMj;}555~4QcAB=mb_u)8M0)U^1;b$OA~2or>^G4!RoBd z*T%CVuoY2Zr+p;MIm!&Ha%%)$1HOg?GetG{7ya~3K9`}v)%n}5N`IMwIa9IqVB-7! zYU%Rd{K~uS)jSymSX{j?$AmxF} zSLyf^Ik4iOXO@77E9?vMykO#P`1JrUBr)G=-f+|wY2*XHe_jcQ4!26_#d5qPIS!^hN$;i)gF$W~#pR3W;s36@}=62hJ$oEm~|KhkFQ zNp^c_ceQi6wD)?rJe5zv3-aAHUwZCY*g^#-%v8h76DG?~OG@3}_fER8Ynq_5KdYJJ+N2N)~LJfQP4u z_o!Q|@cChTTqPu;bFb*tt!e%{O$S}IsLns50A$B`pN5$o+5Ha)6V!IuO4Ut>xXGvB zwZnDjd?}O!)N?QP&xXDW&rPs%;>KeHl^;-s#(-`sg9mb})h-Hb7{6R;m~2XJem&vscIP%c%P-23-N2~_73R-{o68WYf{q-EB3^xLQY zk1f{2#7S6IkPp!29}W>?od?*h7XJ~tMF)-36hP^6`-YEW$MU67omSxAK~cwsjA!|p z;J4%IPSDtYk#Xnh@Gaj@;_SR&L zNpF6ZV}TOw9@jukr7gh$oYL-Fa>FLK6kbIJ@F6SxT%Ta$w^UJbru<1wC$h|06;kU|WR%%A>3q`I5?w~4+ ze{hu+R@%E~>GN?z9Fv+xKf-2_l9+tKe_ZYj=UpKk&_l$bK7oF zRHW4&4Crc>RrBg(GB$XcDK0-+2CG)+6wnCT?(Du z3g>jDT|#oFixM|N)G|+hiH6V|vju>Wzv~y`L*y4kg%VPPL1u#Ipv-DNpf9{6UF5gR zC!fsN>R+FG$#a+j-15eNv*;MnfLAx`MXneO5`HdNWNULa)wIsIzHT?)|Ituv@q^CO zm^r5fk_Mq(LDjcuQ2g4i)Gc5O$O8aVSx|0?AveM_h>Q z*JtvbuV}ZYR3C2O6jO#&KGKCvLq zoEqQ?m1sZNrSP58PAswJZ}rxudqb_YYPgVh;=#%Okp8;%jkf=_I-K!waV2AdHSwNJ zIxm*sSb_4C6|YH+tx9*uq}&~5QJwn|eb^vo+y-=5TEMraiJK=?CwL~z-p08*X zKxT@Jl?etkmcK$QhCU7yO%ltuUJb3QD`_E?9L~(mRci< zS@fsKMwhRzZnqlen)g;Y-DHq;TR~d8*E2dRLkCw!aHM^M8JlR+nNjFu3)3#9n^+s6 z1(0Jvhgt1b05Pi>{Zcdc+=RJ$KZ|Z-^W*ct`!Gr5EP#lW8M3`*@+ER`;S!)f^fs85 zD?IWoa`BYsY7O0R3wg0hdxCq%B85GFd@;(joJD5AQTjKx%lk`D{b1%2Ed}AO<|C38 zADkj@2(hwqAJy3aTvZ#JyC%oz$vH4VonqSrGPjUYfy@dbUdPu~vbO0G!mrs7`uC78jtq(2=$BLZZ3ICJ zY5Mffqg^&C&33Y7>gk$bVCpuZ2L-!(LT6OOqt2?wn$J!Kx>*U!v%c7-o3JG*N?7>7 z875x4A2$Sdh0|n5FD2&Nq??s$a>$hDN)J7iaO`@M956}oKb1O`khNdjgTZuZf~r9yHWDCH6kZI{9x;C!_Q(> zJ#w`oJN-Mh<3j$hjNU`ba=Kva%ju&uy4<)n)P8;sB+kxR4)}9 zoRq!Zt3OAe$yXMUm-%YzU}YohsB#OA)k7rR=s6NVDw<_@_K7=~05PU7xQpeMZbvob zeXx?9VJh8GO!SSS-5>$ZPuM9xA?vz**~-<0VI=m!2=9~6mcO%udY{e-^c$dAN{BRk zER5QPJX66FuqU&q1nQrXh)LBlwr2iXrnq>2KY}&x)4d9!k^P&Ffx?W#O)N%&E1KNq z-vu=IIs&Iuw*0mnAYGFL&`$(zBFIgE{!fH?83kGrQlLsgADBl%Veb7;0*FLkUs#=3 zA(FyU>VDh?kLD(~7u?vkKDM)iQcynvFH_awMQVQE*V$RfB6qDJf0RP9Ghoe{X^J= zT=Qz6KaG9O|EhMz2d@}uj}AJy$g1_JJ_g&rCMCCHOJv@9As15QjaQk!U(oZYr01)D z_;YqCSH$miM8dP9-p?<9xhz`=4{J|_+&uf@WmnY%KMeV#bNyU3v=&2F`(zc znQvQEFW1f0(Y6T$!O}w~lsk};q3w~Vn7zEBq4w^}NXVJT)1LbWycfFBOs-VarV^G) zAELp541L+|r^AGcqDR}Wa~tSPrt((z6^~LKA+BZ+WN%Nu!v!Pnd(G}_Lt0^kKn zvZXVw+L^dJno`^gD0XCe)1^{(>=S9@cv=5--}k%kQa&C4BX3fE<)h+y`-ep{ScOaM z>zCDcs|;tY*_@dU6#l_}3ac7BH{uo!eKcw9#^2604gD84Xg;V?1G&~Ox{y1N%5TK$ z0-q%qqUUX^uuzx8=jGgH8jtO-r+ar?ch~GvXI*~82oHnes?MSAanOv|I-r*H{2TOd zGg#X0qDr+MkZ5P?Ie$_%MqrRF=OTb1AF;w+x}w&)5N9c!^Ki7eob3eTLh6VARMpX6 zEt!6~CA%~e$@9~qAgDZhi2_L-154qp??Rx1w|kcS&3f)~;Fx)mP%H#7pu?O`fuR!f zm>(I;EL&QJY{g%HJ1u|x%RT7N?%%caOeeRA(uVJZPx5oA`y4di*}hJ#Euy=)7FoH` zeyy=bJq1HOYI{ZX|8@cRF8_@`zjDxh(d~hW6#UTkZ-q>^MR_E~$ru-Ekk37AocsFG zG5;;{4^dLeQMMkXtM0fg8_LyyAg-$G=1W|Ea=KmXHC5}@TPnb0CD+Qt+_ajf>@FR1 zaUF=+g>udbB244!r4>`;2=D!FXUQl*T^@=T1nU_kU#wi=E{q?_ZUvCb4O^dzglOoh z(Qt%R#j5@)*1p*b*S2kUN@uXj{t}_C-2f$4w2|u2Hr^k-jkPF_Pgnp9zQ*OJMdn|6 zceRNv+VO0h({LE~4%P;ax2OU%U6x6oy46(O{}p$D+XSnB#t%|WyarOlFKkgVR06dSHyRzr+UDDq{_q>tuymPjBac@H8@kOTB-iT$xA*1K6i1>!5O^Ij zJ}~KCC$6=#!{6pMfW#Y+R9D^h>nmP*;U~d-VSjTC9GXMBW*w-PQ$p0AFmcX9Q6Ha^ zOa;`LEDmejN+LG09^!t6f5h|>RhhuBAD;ik*-WB^zv<&>$l+?TJ)@cV!+}_G5nsK0 zpUPIY3vjEbzKw%aQ+UV(JgH8{@@37X7Lst(GYPu#8lQqRtI z--lx7u2Sbpq8&Z&>Ua}gL67Ql&-kEb@ASSLs3`I4F3A34R=-h7`2D;1+aPpL{~Hjf z(WkONuf@=FLn3wwG;_14h8_EU5NGk=2igCT8SD!xtUNZaFHTL;Q3X_hb5Dt7+fxdxjjTLR7w`{xcTei3fR@i8pVga*yuC-(qAj`hP0 zXO@P<78VBwpqVQyUz3+%yy*(VMYJY6*yr87fN)&ATkt&Im7#I?zu#6;z&o8I zRZ3s6iU*T=#3*iEzj;#AEBD=lR6l5Em6G1ZySE%lf@!G;kN9#UEK>jGYgF$wSV2*S z5WQ^wp6NJPw+az7P{QI#`dB4&+<#8-{YT7k6$kO@&mvi#))%oI{a_%GLG@+#+9b@c zl{FHgZeLGPAb-V;twF9bg4&Zo841z}+GGurZZZI61_Uhiqt5LUv3=?-0`jk+NN($! zZiqSL*9V4niW3E!#u)y(90AjmW?MSdUx64L!`l{1&AS-uplBQ4_14~8c|DRnuTwUM zyemCDu4w+*w;(eue`E>fO^TY|nO}**RAH-s8;qJot8S;I(u($V!+ zOvVml+mGVPgGTumdKGSpVAsi8{2HYit`Nk3_(hbd>R$9}KYt%gFfkPo7fEa}#3w*s zeZN2tK$V45-orsj7EAP0S^K|K=5|%`D(VY)>aGE|#Z^@xC*!RQ#zMJ6a+Ixf{&kND zbE;IFTQRyS9hJ?R^y`1Ras?hv@x@rGj98V<^X$^Kh zcMe5Y#yMRNDsCmk~6jmuk!85-YQkD_k)H&mRZ9#;;8hw;B1VjrCux^- zIoABb_Yk7Fo-_CyUkw#+`-CQ*vE}Y!*5G5sx2WmYz0gdT!U^n3 zDQWY3nuU)0!Xr6*dc5xVUmjFMi|HZ5~i8yY4(S_%UH+aBvhHyR3vpblz-ds7i z7eEr$&C`CGOP-UBCc;U)ez+iQ;egQ9O?F4YC!qtKsk-0J*Jra}=7kTmS$dL;YOFqv z3BW*x`2b%kq#D1uWILA^Gu@TRx?C0_B%{okQobI2Lf`PgK|PY(C2nTTg>kYy!FABL z6rcx94XAO2tmyq&Pv;WZYfZh%7}Ad$?cs5I!NpovlP+as>r%?r@9r=5Ar zqWn2_G??gR{m=t-xb_sS(irrU?%xkJTW<}+t8J&2rkK@mfv%FEGWdF*_m^Ha07vDL zkGgyoBS(}jJjN*ad3fo?Nm<%i0Xfrl5rBTlbKTC9!xi?U%3JC17|b<;U~l`<5V^z& zPMypLp#p_RR0%lXc5EXCl7*Y!-Z^2TM@NVOna-!6#OzJ4lYUu1p6ICr2wEQ7eAI#k zNebKi{WI#nUz2%W$n8e+F*K&*9)!g+P%@tg!krwv&khGd3o2ST!{!rLUzcDO=6y&U zU}tgYu<)!FXgcb9Y)(HpwtEQnvOKbJ&~rtfuIHreuN#*V07J<_~zvi+sD5{h0Em_Vit};nx__CS~6oukq z>jW~p*b%v$)EO2_=F-~*zr5>k0OuAQhADpX_J1MQ>ty{t%FFrjXcsXMTs(T&5m)Rg5DPpEnMj?f&=~9E#ZUmBvqDYdw)p?GOr8M zzCaox@;O)@kamTGl`Att9xVg#OPiaUxyVvP6L#S8qwV&Tw(+&yt&+7Bqdesobv}YA z5x)#?Rz%mD69(?>71IwMJ&j!UP~+IYHu(G2Z}K z{Aodymzt7w00$<s7tsWh1Eg0*HQv$9Y%1T4I4KwN5{@y>J$Q*x3aK4k(@u!fr z;VS#11@BUE{~~hRHtc=v~}X`FYDZE+?63`yVdA{R-}LMNW0g@z~za z2_@pMezTGSgqgY5@uG!-IIz=4JB|f8Xxp^Rm`t_mUj>xeU;<3++BLX#4RG=*O;{J4 zv;L~boHLu&KzbT_v3N+R>N3JIZkn^$v5?6v`dcL9k)@K6vb`!U=W-SwV$ty-F})O4 z9GI6LPd^5x%a<4m*urm1<@rj~nN5M15je7N!D!+s;$Nu*Nn>wK3=BEiB0I}1PqL}@ z*%S~YX~eLoE!=quFc`YCc#LwRazdvLv7$oYfPC|h1p*5?5KNWb!-MHWmIFzWmaM<* zEcHTyc_2wDCuWt8m<@q|h|@mC%s%3O^B4e-Z>(?oi#>>5 ztMl#Dp=L-VFY8M;+h5;8HMX}4DoT5GZwZ1nrfHdIeoj}kiPz;d&l^MgPx1O3s%r z6Gv~qQd>xU(fCs5_G=f1@0&n;aJP${CA|ovViMc7tWU!n_wnnj?#=jPhlH)Rx*azj ztWTp-hA-YSXXGZimWw}zxJm018odRMV%^kk0JM;YI7bMJM<32_DeSnebrZ0k8ah*8 zS97*|P+7!mB&qp0ME(-8-jy28db0XyfA56hL)F2i+tvC1Qh5Wcy(Kvok5UchgJ+sY zv7mtTqL<;#rS9wcgsUz)$?u8n7anl3cZIRjJ&uFlbL`wuIDJoKM(IL3^*}mB4)6`@ z{kx)}L|B6tw?IYgl_WbLLfS?~oVAm_IXZimX0B|J{SwZn{8T)bIsczt0SH z3PXiQ4vR11oBl#3MtvmV>zbKWg6D~~Vb>WkPWBJThanfwvdd!2`Hb!hQ*Vp^2>k{B zUl!|PE_^goPLULdp#K6$@c-OyHVyqK{I)-v;1uuN3neax-aU1tK+rqNQYm%U+*A#*6@j~`A2>Q9B;D9 z@6_*yi2vcD?S+0R!(uU~%@CA10XdUcsweWV=yd9hEuBRi!~##rWx#2^WQMM;NaG@Y z=z2~VVYb8r=%fqR5RQ=nBF<{o>%bNEFFvSaF0L789H?VFiw1Y)D@zy3Y(6#5PpjR1 zjzZ|GN~n+0K4Z@23qxazfpnm?xOU@|$%*OY4x?AK&ZL0X(S)NV@aw7cbh~f{HMtt; z;{b|#P8lYdA}_^ARo5 za#pe)o_yxI2Xn-;1XO`+h@_ADR9}Xz(`^BSLydxu>?j*`%nWu8UkORd2J->uoAVa1 zm&84Nm%<;=BUL-jNSQ8DxG@7SP0m8KpGGyV@Prx_WubQe=Ji|R^gelp*il!%5#l2( z7BA@y!P4N5y|`z;B)tp?r0GtVkbD(#kiT$~s2fSsACIXH@%qp?BcuS&oB9N{xoO$9 zC;WQ2+%R|)phqQ%omF?qfBfpD!C|_VIjsUFZQXX{nt|@_)y=J$vEa#T^(S+~;kf`( zs)0h|g~lEJSwcO>#b|r8UfK6 zo`?rRm=tBkA(kbdXhj9ym&y(G8Q6wc(DNwBEzM*XX%O|BQjYTt?gyOQk|fo4iWwT| z-GDNbYh+H4le>0RIl3<|=V1Yx?A_@Hzj$ZYus$t&22iE`RR7A-!Weo&M-z{63X6`J-2oEKMsYv zp$+HT^_0Ui1@DfSi$jrJ-Su(6A=g*$+%x7%{FTE??xuU-f=5P(ifFzsprj{K`kZWw zs;fsu(?uaIw(w@*ZURmEm5M1-l;>M((616;2zR-^R4eh7;rY?%N5T^(tY>_Zox;A9 z^68v)on|C0-mijJ7pB-ip=Xc)PqRbUDN13rBbcG@j8s<3nq9pncexhi0$jw2nt6Z- z&yP!lMF~e#wl|j+x0MSFY<*_oR5=n{aj*0NA}nK$mC{dQRS9!R5M&XT-EHuRHZC(p zZ=0@=cx#ozXk0GN@uL4{478y;ZN`Bbw(53V!#<)A}IweuMIQnHJtSXf}os%(AB;`vg1 zv{b)3Sjysch5y<^!K*DV@Tw%cLZW_}l1QY9OV3~M=+6ht{lU6^HYZdcEi~_^i63j(qWf7S;ps;3 zX}gcG=Dqz6?y9vSUR!RC+oXi1+pTK07c8*yYDzg;4BeZzX{J)FCIaZ;;Ra*44G6P3 zEcEP_@V)S`PsMG6!^I@ujUGiOb1y`bt}I18bhgs-_C!A~h|UgNqoJd9o9Womw5}j! z&bSB-(Pf91o7LD`6DNrqB!`bb_5?e^vy=sr8mGGpsaz-?5{b8rl%@5*JmQFg7oE(q z_Sfvli`e9-x;8i|10QQ*@Pq`uwgQ*(sMji?l4#RgMbTgUj%1G;0#)ZHX#q{1Vh94D zlWSIi!(U1sMn^BZ>JKzG>gk5C#@$BT($hKX{B`|0)g>8yk$gb&@9+H2jclHy9nNo< z^n8|l`)XW$GLsC1I3(-bO5ReJx8T52F9;kO>XoXz9-rR zz8*{D6h5lldHvacF!7QbTxzrYx79X3+ubL3Z{As#r%iSe$vqOH{7=R&eqD^>4F++| z%Q^MZCqEu8UM!a|(l<3xzUus3bx*OMHYI4hFCXkabTMo^npm+5Ue}m*W7lyFwKRXX z5sdT0ze_y7K~(NLxWW*AF=sUKizLQ+3meEL|c9|7CnXd7{C@4?_qHgU*eqt~qq9*QCU4($VKr+ z>8LeCmKWobkfoC0r1$4i&J0a+v-j={N+Cq32sz(H@#AeHxmT0K$f!?HaD`=9f88Kr zvoGqA0nXz=u4@LlK9Hh^7c0{9l*Qteqkl61!1ZU(G1=bgFRg33lx?k*1eicPhz8^#Y?`!>+FTCFk&Pm`wW`n{=oD-NNw zZg%40k6ZE?j`qtw=wbaN6y34WaTM}+Bol5C0DC*A+gkkY0o`GqBVKp#qQJ8@Qg9RP zOge0_#57RTg(Y1pCm&Nbn&HmhfR?ZMW_2?i)o-j8Qxwmnt~KFX(Q37AZ-D za(O_Z3-1GYWTUcFtG6I$Yhxm#4nJxmDGD)j@ZKL$DSu9GExr5|?_RqtPL@p6w7R1-sU#D4a@*Tn0xVM z#XQ1X&jo6i+TRQtW06KH?CHEW|H+yJ2OA!b#80U7WF)-n<>W+E<3;gN7ufHX8z7ef z*&T`E7(ipDk<TN6Km79W_=jTCr=H<_D@yUC$ArZ&>t!eEvS1}?$((C+r*^tLS zT;i7a7vzt(4#EchWCYO`O)TUcuXgn+)K7tnS$`gn;KRK>#_K`4mCoSf;0S^vu7so; z?;ZtpDma5@m!q}^9YzK?Fz|W!N)Yj)>Aeo0WHOT{CgAak=)Ux&>jkV59wu@f`C+$i zrM#4zx&;@NVNV~58+f;NKyrdLr5Uy zw}g9XAEsul4ykOv?Q=LwXv4)JlXOw1KGJfV_`u3U^)0yDL>lO$EFo(71Ji8ZcT@d% z%`Rdkz<}bN%BH5yATtxJ`p&hwA4~8ULH8 z3p)b64bMKak`y~iD4p(GfqM)5W2` zJmIg%?dw&)b^wMiVqb z7I0s}gR>5w+lxr<8YDx}`81#$Hu51=;>u7?iSeZFoBAP@b;Pz%tJW25&L)zp>+7S7 z&&TJ>CwlB~0ceFp90`JtR2(k#ocxH&dX|m*1K^-|)#Isn9DP%7Lo7wlHz~{+hD!(m zVGmVLnfT8k*aE>dpHRd$#H-;r~7dXD`eM`#vSB$Di;EJ9hPyumBM0IR3sTA;h}C4XGX5 z(Fr&=q~F-RNc3fQDQ#n+)xg%tociKy@8{PI&i0>t{>>_D5cewVlhnY^OBL6$)bHld zi$KnsEcsx%Z`%oS)52%t8vgAX;fp-c0l92KKoSPq!jT(v>`OK2tu|2?s{-upHJrw$ z?7fgMHxZgk=c@{W0f4dT z?*9UW%+7<0rUF<;t;AQ2c|JS0UiOpoTcLd6m`1Fd53=I7w9O(n>1TkEyKRIQ%5L>KHUn+Q)?Pu(V+#&Fj!IRCMj- zBv}teuN&r^WXvwW$f&>Whum@UHm0UwEIVz>wO0)CTYO(HdH(3A`ccCB`zmktw$85( zL-%~uwZ^{z-(wsyjV7oBSILULZSAdo431rSYfpCER9l+z$M}*48A0vbP4rm`bYG2= z-&_U35~E+K^l=EqUThm8Px&-Rqse(5v0Q6L+$Hql8SwimlE%+_*ZOO{z&elKo-!cw zaewo;3!tf!hy2mIqp=#al#|;@^Ml&z%5DV4`P(TRfLh|!A z&`MfbHns5o2cP}z!d~AL>inv9A0DroH~Hh*ZsOn<$>Hf(z~p-eBudDWVEF8k0#0y1 zE9x{~Q>3I`TJy&flTmlQSViL=n8fRjXqWUoKa}0)Tar{@t6e762jtiT?KRr+F3v!XWk6117Wo`y{2gm%nh<_>+>85M!&h*C!npbJxay6TuLlbnvmUVnkx=f zBA8JprZbla#$BfjwY+|sDEkyUb7|T-4-fk5i!U2(ef#f!-UfX=dpbAeb+KPvtHQM^ z!)2teovof^H3QK{$=4rjsX|%b$%*g)2sblOQzNJHS(J2q2xv}T%G=;U|7aJ&sLjgK zUyt_IMiDq#M=1H+Scg({=Zf)X= zM(vsBE$44xW`#x#A5J2;xT#ols==eWtNt#Uv>h*Y)pLS%Y4Ygl@X-Q9NDI$LmdQsRRtXQ+}I?(Uz zK+E``>8cePa?QpXzz^>xA?@}7l{C`chgy`+{bI7{r!N6Rn@YStI+BX5{_Bra-+b@) zg2qDy@VBQwR;xn?svT?!!DLy#g<*d24ck5%W4yvnfwb>r1z2Xfj@%O5-pZPZq-oge z;KU4<2Bfl&nr9!O#mu#Scww+@_a3q13YliyvqLlUFDMa32bv~VpN@+MT)bn^m7;FY z){iTstuBx8htgi$nfqySn6}=cV9W-fRt|Q6q}=b-!F~tv@iW9()G>3UpL(yaE_YUi zrEafiroyYq0)R~Xo-cjp#uk>$#Bw$(=xn}imH$?~H=V)9@>){Rd?+v%lZig>DCp$sF zVcY1&(PzbDJ>=IXHnF)A{R;tzjUKi6>mn4hH92>23Rp{LVX0J_15w^)NbyH(a8e(| z&quv)SM>E!m_iitayKoXZ;?M8Yx(2Wvwl)n$nJ)h)nK}nfa5hko}ZDheUWCmsDM>W zl33yzfHb8iQ4t8%@HybwltEvV_mYUsa3eTX0b1rZ0&HHO_dxz*PHGBs45a<^S^1w} z(dJ7U=r=r73JZe7$Mq=hA zOBGouc=XASk@z^e!*1gNhtca(IP85i=d#-mH)-1=5;wo^YMRaiE*1l8U&~_D0rKV< z42rX>9UZ)^mw^(01l(8NKt1>VE5D0rLIppbE1fQL1Ug9foYijyNR|6Bx<8o$76){4 zAv#K$<6nV$zHWSojtOiBNo0@V@?f*vw1{qz3H!+&d%&5QdFJxwN1zjCs6o5Et*)+T zUwTo2i$N;2Y4Pxnv?TK%aUC7`YDnGls&R@Ll6TbpHi{w*!eSS+mv<;!M=V-w!idkJ z_JPVDdDnuE+VY%mh8S@Guh^E)R#eQVih9=BG)An60CeV0Zmuq^hbI*$?P)Snwz3phMH?6w=}6mHF*bg1w?D zsR$hTP0e%j)wPMS;1~S+&Iod5O_v1hx|dI%lJ)rp?+jwdKeTi;T3~7s8dS=U;URgK*MVvYFEjvzI2y5R!oq^x!Pha9E>o3Y&wA<+jbj0nlVJ zv8NDGTRS`CuoJ=rtLyGa(6nInM`fiE-CHFNl*`U_)97gbolKw^!Hg8erDWXA=k)c4UukI#ST*LM z;(AM?6Xw>gbj#m=rAW>?ErfZB@#l9%G_eGYw^waANikCyO@^s7Vh+|Y9lLJ~B9S8s zD$(30Rww`Yt$oNie?L1*M;qq)AIo%ww^hz`zCwD=3l4nx{SO-T6|Tk7FR zs%`YQ9KQ%`qK;Vkg@z3Jq_}18WYqGqYH5=<6+9YJcY5eYDuiMY6tw9(Q&5)?koYTu z<8zW^h>k$80WKXI6JNhlTK}1pUpEFsFVY^$gRiVQtiyWyJ4VN(p z95}4TP&H9wajkYuA8CwXEnuY`)!z{zA(3=}tXx<*NSyhuCz6OlpG>p($kZ_ZZv-Cw z=!k>_w}&;Go0OiGHA$4-glHjl_bss#@7R?Q`>R=jocibiP3ZBk$OQ0vVoau-MeHIO zK1_&7iEgpY^oZ5tCLf2ajR98jz3)e~1K%e`+ZZwuRe;{X+{~=GVq6Tlrbs^qBUYCA z-Q0Wy{6QR(zztgGiAaPYZkc&&mnunSYHCw@-h}ZINdx4^mT;OtPtUK{*LSAoxO2~W zpbUWs9>cM4PF7BU(A{nM^L^Ocw=l@4&P)hjeuY{yTnaLJ5I|~~LN9GG<<#;h-%glJ zTiUb#DL~-wZw(rx!6Q;qpWb{$s@u{cZG94jlzLh~U*!@|`4i?B<#@po6uM>6nQHFJokj%<6&teRb zob%jP2kIKmi^iaDA**^!(#i?B6cQBVSjiko=nh(4^X;f=%Le58D{)MPsGl#amoiA+ zoU5iz>0HN)2~pu~vI5(Eje^CcFXbU&0Fw9(li?f3|MXqmw^Uy`>8h_mC$A%R(=W^U z@wi&*7fF*#Na3CS{t^9|OZ}NNfHZz5Z~WmN6@^0#I+i#2Wy90=xB4TQ7fy+YRs3&N z6pK}<$E5ODN=p@`qUJN!Edel zE8{i7fga#8f8=k6vadu)y5Y;iGfGyEsnSDXr6;n$cC_EmH$V;fKssk;-7?HXyL^($ zN$PX>2uMfagUri?+(xRLMFRp6N9SEIOk?9mkR30y!i`b2GQI>ev~_+SCd|qp{JKHa zJub`*k)F4K9Rn2m`bRdwNe8IGV&y229E4&%@O%8rUb@9n63q(7U=pe~1IHDizQ{ zZGi%@3U4+CR8f08-CGW@v(=OnEENHh)P5eVevHCC+4kF$#=_S!ScPJJG@fQVCo`hc zVJ(0LloW7P+hRsI0h?YKX`ZBdT?K9xTVtP2Uloiv{2OU)m-zlYCZHmmCR{!POn9|g z#TUYMh3IC?>wPPHH2GsyGcjRBqx@qT)2@RH(w>QuhlBohI zBF9s1zD2A$I0J@M88?*B6@t-A#bL?%FQAjI-R0O)X~4)p1%|Fb*(=r=Q=`0q?( zhYZseu?~X=iWXisZ~U|eBVr>=?zK%8E3~6SLa>z&6LL=Yt?)L2!y{p6dwHr(#vNs$eup*4DX_5yb+9_)x~r!ono~M2T8gFDxg}z`!@KKG!!z zb`g(xr%>sJ&jdCOy74$eIm~yY>gfvPMDbY-ta)SM2%K(IcK^{pS|M_Eap*8qlpW?+ z@YVcc$$&P)`vySu<}JdN3XVt3m?C0lkIpw z{@ErQz_y)H07-{!6xIY~j79$YeGCJ2|77c;CdJh&CRC`cLHYH8VKMelJWwIu=N6=l zxR0nyHvHzw%JhgG6!KR33J^7(zNlCFk%MDvS$1Y-pYjSQ3xI^{j z0F%#?H}{xcqYam{GCKwkjoep-R^vD+g8Bn=+wPcHi^1u>-`yVv2X*hP zW4nqL^+N7xVMSsR74Z|~pT3}lZK?yeveJ=n1Z;F!t3| z@)r9sL5Sa2?9e->l8l^8Pm||wo*u-^YW_BS)=AUNrCGVplsikBjp+h)@v$Ei`zpJv z^SRY&(V53jX!_Xk4@;JlG1NUDpq??s&tqetT(9{HK^NG8nck^J|4y~eN4I~mbH87; zJ!U$E)lETIgPs%^_l4{1K-gZk&^5}VPbI>7z8|q64XNXEq*K}!Vs#{Nb7@;cL6J!M zr&uU)ZV3FjimzC*g_t?IT`fEdBN-_ZEhkG9+pa_}*LAN>euyLQsq;M$K*KlxTRAMM z-8rpmDpOgzhMEeC88Oe(UEp`UAX2^miaEo&KMZ*yISRk2@zRojuT-Cjd@OB}Z%y zGijR$<6Ac1e8vWrltvp+rsMj5JPgc`Iu`JgU1z)Rg*=UC9&Qf3=cT6RPrg6{o=H->mP-Sld8 zKBb@lmueNggcNp0+>l$UvRz5bA}3H9I0S82aCf0zGEj7eiu)wjZFsc3VF~(@L6-Wzm|hb z?4mz1nQkxpHD)L+PvXCz(C#^&S^VWVZ||N(ruVa4VD#bhE|UcBp0Md|32${grr?&w zBWHrKlP+{-aenAcYtoB!ZlAZCdB4h(qAAPEKCA%iUQvxxGCz&kJk-q#4pR+fHA;Fi zls_sbE->}v0YMBCK%*-4A^c(~KxIAm`n9g(gL|gp%PfkNYF@?0wAw$m5geey(bBA{ zc6JyZX(eI};w}8y3`4e@o!^+pMn@dv_zc2svSr^u5m6;d>#&mlzD)evRVQKaV#?iQ zT>?DL&^E87)Pp6yQvU_E;q2ZnZal7jI7#tAP>-!Txzxv(&fJ}4W%S)k_`5g^kUDM| zerLVI?Cgw0p(F_cC8mJ@-}A7YsOf1qg%h zfvn*R>k*3P-gzyv{JNJ>5!xN=b!b`A*W>!uQh(LblZ?W$0AgW?cp@n9eIVb?@L@~< z)MjKASLtJv6vzmnWT>nDj{JO+(>KGolx${VTrdqC9zYU_(V6V>s{k@GBhb*e#B*Gh z{az|l4jA#Fa(jLK0=&<3b43p3oB48so?~d5{C6E2pYp}X{4DInYuz=2bs~3TFAZC8 zmtOJZp%3N2^pG087|mB&Do60|C5fN-V{1!i3cGs$F$h&Tj2qGhD30#sz&n&MU!w{c zUMwj#`14|myo>7Z+0}jj-n4L^54$t4-rvGPI`Bjv1jc3e{g^6X#+Q%ab{mIK%g!&^ z>i=S%?~V?l*mVM;f>y7qKYi3@L_LsHB5~_^yiN3HHVYBbes2YhQ>(a zgT)G^epU=;6EZE_NID9D8_>itoXrCkt+;Iu@r1sCPW18Y_92nu{q*AbQ!H8h%MV4O zq_cJ)OyK!3^k0Ib+J8^DvF&Z}aK7bxG-C$JDsO6HdkJ^Fk{U6Sp?hgtdULmL-_6hq%-MkV(f$N+S_4`{-INV&d~awxDHeo7ym@$WRNDDJ`U;| zV6G!SNac`7!5wVMdT%hedN(>UbRBBC+o+FrWd?dA1w6rqX3_!1m}w%B&CSuUVWpbb z#64BOHqgPr%tjxF$W?pbKkOP`zo!eVVe9>4M5d_yOeM^LEivp(9AHG)FLqwrK=h1H z{?eEiOugDTSDKm7>qa=--=H7a=Qt->Z8MpLVMwoUYQsyi)}N=^cY<-}!{VoVnE9*E zItl5R%)cn{buvAC?m=(f=F9No6cb+vZ=6Y>S`=w54XWh5>>A6f(pDzG_4ty0%kM8<;6J#I>lX(jttV6BFbgwj$uz zz?OgRN66Gg)ty5a8%8oyfDpFx#9iC*yXe{XQGEHU*i=9|r8q|=UC+d4^c(zpI2GxI zSY8#_Yr{aW2sXZ`)=vU|x0sji?GWyaf>^vvOL}0iHfr&pMD2ifRjyz|2cI%&`Wb7! z=s?lg;IyYYbo&sF`ATJKk{2TrG7JSC3rN&+HdQN}#g&WJ`G6M!cIDvj-x^JPOz`|~ zm;U;lb5GsYwZ!8JJZ;D`w()Ed@O(Qd-q|^^#UhG+!3MK zqfPSuH}p5Ptv(!P#0x6R+kvj!Ts{&bs&A>e!=NX|vpe>5Abokv3#oD6{ey*J$QxTw zR!$Hw(z3Hb*0EB!!H*PQcvWSd#Musq_`@w zo5=rA|5}|lZuts~Dk6bb1M`-ckRY@kMu0ei5)&Vz99^73zF7BsN^~RGZlMuX#|a7& zfgn0P(FveycnP<9yLW(*+(g{E^7Hh-Lc5(0ha1TkZ4Y51IVS7f8+(m8MU7KDZzu6u zHR>{GEco`mozHp@a^&0abFFw#<3!Iu%WC`Z)h|j+KMGnS)qcX|J|SXv3}z&)hsU6j z&3m-eQtMjH$3Ov~^_Nj-T|#!{Fr;L=mcDW@1YMrCgai^B3Er2K!o;BZ(_0$Y+BzgL z%5WlH2NSTCK)vCyrD)g);dXr+I;aB~8R_iBa@`M4$~R76lh9R9w@G__jeKnKCEbXT zRXN#bSJ)*A{TT3J8Z~+ME0Zwk;pcA?w0GWK*Y`R^g>#1-A!F%lyFMPVL<-ok_d<^` zq(VV&15JEprTWDL^Yata;gR?Kjb&DYVcdi;IVE|QMEPTce zPzisMu|0g)2y5*2oxl1A262n3qQ`#WQrN_46#S$Pko$S`CvF;n`)`$f1Oh>YnVM+d zF+6Hv(nL_}f{@xLm;-0%s8p$ZXda`UirFsON&y z65mCP(r9ru=S}~VgJfYU_{7t{$F@=DvasCP(t*Z&p*jD7^pnO-0C<*;>!*8MzX`9);|~+T@R{qD@*M3@K3b^zVQBx|Egbw6#@Nl7TXn)JM4=rORh=l^x@MmacMivahLIp8a x?YPZx!)xJ{BTOzv;;rf09IXEzpV+nstR9^!@>60|D%kNJP*c`~)+kzp{~y84Xzu_3 literal 0 HcmV?d00001 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 index 899f61f9225..fe336dda270 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,39 @@ +/** + * 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) => { + 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(); @@ -6,7 +41,7 @@ self.addEventListener('push', function (event) { const options = { body: data.message || 'No message payload', - icon: '/img/logo.png', + icon: '/img/pwa-icons/icon-192.png', data: { url: data.url || '/', }, 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 %} + + + From 5e5250ce38f402ed0c4185852ef43204162bd6ac Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Fri, 4 Jul 2025 21:55:56 -0500 Subject: [PATCH 03/13] User: Improve push notifications handling and error recovery - refs #3255 --- .../vue/components/social/UserProfileCard.vue | 27 ++++--------------- assets/vue/composables/usePushSubscription.js | 11 ++++++-- public/service-worker.js | 5 ++++ 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/assets/vue/components/social/UserProfileCard.vue b/assets/vue/components/social/UserProfileCard.vue index a5febbc8432..243cb3a7596 100644 --- a/assets/vue/components/social/UserProfileCard.vue +++ b/assets/vue/components/social/UserProfileCard.vue @@ -101,7 +101,7 @@ class="mt-4 w-full text-center" >

@@ -124,14 +124,6 @@ @click="handleUnsubscribe" :loading="loading" /> - - -

{ - loadVapidKey() - - if (user.value?.id) { - console.log("[Push] Detected user loaded, registering SW and checking subscription...", user.value.id) - await registerServiceWorker() - await checkSubscription(user.value.id) - } else { - console.log("[Push] User is undefined on mount, cannot check subscription yet.") - } -}) - -watchEffect(() => { +watchEffect(async () => { if (user.value && user.value.id) { fetchUserProfile(user.value.id) + loadVapidKey() + await registerServiceWorker() + await checkSubscription(user.value.id) } }) diff --git a/assets/vue/composables/usePushSubscription.js b/assets/vue/composables/usePushSubscription.js index 36f8ab70a82..6040127b4e9 100644 --- a/assets/vue/composables/usePushSubscription.js +++ b/assets/vue/composables/usePushSubscription.js @@ -4,7 +4,7 @@ import { arrayBufferToBase64, urlBase64ToUint8Array } from "../utils/pushUtils.j import axios from "axios" export function usePushSubscription() { - const isSubscribed = ref(false) + const isSubscribed = ref(null) const subscriptionInfo = ref(null) const loading = ref(false) const vapidPublicKey = ref("") @@ -98,10 +98,17 @@ export function usePushSubscription() { subscriptionInfo.value = null } } catch (e) { - console.error("Error checking backend push subscription:", 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 } } diff --git a/public/service-worker.js b/public/service-worker.js index fe336dda270..0e2945ab127 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -23,6 +23,11 @@ self.addEventListener('install', (event) => { //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); From 3184751fcc430f2b8ab4971315bb401463d9305d Mon Sep 17 00:00:00 2001 From: Yannick Warnier Date: Mon, 7 Jul 2025 15:07:24 +0200 Subject: [PATCH 04/13] Minor: Avoid use of typographical quote (not recommended in term translations) --- assets/vue/components/social/UserProfileCard.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/vue/components/social/UserProfileCard.vue b/assets/vue/components/social/UserProfileCard.vue index 243cb3a7596..de4edb112d9 100644 --- a/assets/vue/components/social/UserProfileCard.vue +++ b/assets/vue/components/social/UserProfileCard.vue @@ -113,7 +113,7 @@

- {{ t("You’re subscribed to push notifications in this browser.") }} + {{ t("You're subscribed to push notifications in this browser.") }}

Date: Mon, 7 Jul 2025 15:14:20 +0200 Subject: [PATCH 05/13] Minor: Remove unnecessary capitalization in language term --- assets/vue/components/social/UserProfileCard.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/vue/components/social/UserProfileCard.vue b/assets/vue/components/social/UserProfileCard.vue index de4edb112d9..eb299c30c0f 100644 --- a/assets/vue/components/social/UserProfileCard.vue +++ b/assets/vue/components/social/UserProfileCard.vue @@ -142,7 +142,7 @@ {{ t("Push notifications are not enabled in this browser.") }}

Date: Mon, 7 Jul 2025 15:18:12 +0200 Subject: [PATCH 06/13] Minor: Use existing language term --- src/CoreBundle/Controller/PushNotificationController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CoreBundle/Controller/PushNotificationController.php b/src/CoreBundle/Controller/PushNotificationController.php index 13e39da1438..f550a11f584 100644 --- a/src/CoreBundle/Controller/PushNotificationController.php +++ b/src/CoreBundle/Controller/PushNotificationController.php @@ -52,7 +52,7 @@ public function send(int $userId, UserRepository $userRepository, Request $reque if (!$user) { return new JsonResponse([ - 'error' => $this->translator->trans('The user does not exist.'), + 'error' => $this->translator->trans("This user doesn't exist"), ], 404); } @@ -60,7 +60,7 @@ public function send(int $userId, UserRepository $userRepository, Request $reque if (empty($settings)) { return new JsonResponse([ - 'error' => $this->translator->trans('No push notification settings configured.'), + 'error' => $this->translator->trans('No push notification setting configured.'), ], 500); } From cc15a16c5027e9acdfe35417192b39b2460e4707 Mon Sep 17 00:00:00 2001 From: Yannick Warnier Date: Mon, 7 Jul 2025 15:26:58 +0200 Subject: [PATCH 07/13] Minor: Neuter language strings to avoid branding --- src/CoreBundle/Controller/PushNotificationController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CoreBundle/Controller/PushNotificationController.php b/src/CoreBundle/Controller/PushNotificationController.php index f550a11f584..c4cbba954dd 100644 --- a/src/CoreBundle/Controller/PushNotificationController.php +++ b/src/CoreBundle/Controller/PushNotificationController.php @@ -104,8 +104,8 @@ public function send(int $userId, UserRepository $userRepository, Request $reque ]); $payload = json_encode([ - 'title' => $this->translator->trans('Chamilo Notification'), - 'message' => $this->translator->trans('This is a test push notification from Chamilo.'), + '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' ]); From bd829bb739e0717248f85c8e287f145ee832dbb4 Mon Sep 17 00:00:00 2001 From: Yannick Warnier Date: Mon, 7 Jul 2025 15:35:05 +0200 Subject: [PATCH 08/13] Minor: Neuter language strings to avoid branding --- src/CoreBundle/Controller/PushNotificationController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CoreBundle/Controller/PushNotificationController.php b/src/CoreBundle/Controller/PushNotificationController.php index c4cbba954dd..e8c6bff1819 100644 --- a/src/CoreBundle/Controller/PushNotificationController.php +++ b/src/CoreBundle/Controller/PushNotificationController.php @@ -138,7 +138,7 @@ public function send(int $userId, UserRepository $userRepository, Request $reque ]); } - #[Route('/send-gotify', name: 'chamilo_core_push_notification_send_gotify')] + #[Route('/send-gotify', name: '_core_push_notification_send_gotify')] public function sendGotify(): JsonResponse { $user = $this->userHelper->getCurrent(); @@ -171,7 +171,7 @@ public function sendGotify(): JsonResponse // Prepare the payload for Gotify $payload = [ 'title' => $user->getEmail(), - 'message' => $this->translator->trans('This is a test notification sent to Gotify from Chamilo.'), + 'message' => $this->translator->trans('This is a test notification sent to Gotify from this platform.'), 'priority' => 5, ]; From 12c68c68e5e7cfd6676d5993fdbdc7ec8a2b1ebb Mon Sep 17 00:00:00 2001 From: Yannick Warnier Date: Mon, 7 Jul 2025 15:38:07 +0200 Subject: [PATCH 09/13] Minor: Remove unnecessary capitalization in language term --- src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php index b59499dbc7f..03e0e5dc824 100644 --- a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php +++ b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php @@ -1038,8 +1038,8 @@ 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.', + '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', From 8bccdd36fc6886f3d540fef6a29a6bdf2179d43d Mon Sep 17 00:00:00 2001 From: Yannick Warnier Date: Sun, 6 Jul 2025 23:57:18 +0200 Subject: [PATCH 10/13] Minor: Fix PHPDoc typo --- public/main/inc/lib/api.lib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/main/inc/lib/api.lib.php b/public/main/inc/lib/api.lib.php index a50840158b0..f22aa0cb7ee 100644 --- a/public/main/inc/lib/api.lib.php +++ b/public/main/inc/lib/api.lib.php @@ -66,7 +66,7 @@ define('COURSE_TUTOR', 16); // student is tutor of a course (NOT in session) define('STUDENT_BOSS', 17); // student is boss define('INVITEE', 20); -define('HRM_REQUEST', 21); //HRM has request for vinculation with user +define('HRM_REQUEST', 21); //HRM has request for linking with user // COURSE VISIBILITY CONSTANTS /** only visible for course admin */ From 2a65a9406108aba5bbe98b248b692d8e8cfe2514 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Mon, 7 Jul 2025 14:15:12 -0500 Subject: [PATCH 11/13] Internal: Add dynamic PWA manifest generation with theme icons - refs #3255 --- public/img/pwa-icons/icon-192.png | Bin 8740 -> 0 bytes public/img/pwa-icons/icon-512.png | Bin 24789 -> 0 bytes public/manifest.json | 21 -------- src/CoreBundle/Controller/PwaController.php | 55 ++++++++++++++++++++ 4 files changed, 55 insertions(+), 21 deletions(-) delete mode 100644 public/img/pwa-icons/icon-192.png delete mode 100644 public/img/pwa-icons/icon-512.png delete mode 100644 public/manifest.json create mode 100644 src/CoreBundle/Controller/PwaController.php diff --git a/public/img/pwa-icons/icon-192.png b/public/img/pwa-icons/icon-192.png deleted file mode 100644 index 9c4cafb9a74b50042987c1bb07d3030a330eda77..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8740 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 diff --git a/public/manifest.json b/public/manifest.json deleted file mode 100644 index 2f7c0f0a09b..00000000000 --- a/public/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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/src/CoreBundle/Controller/PwaController.php b/src/CoreBundle/Controller/PwaController.php new file mode 100644 index 00000000000..3766e24d2f6 --- /dev/null +++ b/src/CoreBundle/Controller/PwaController.php @@ -0,0 +1,55 @@ +getVisualTheme(); + + $icon192 = $themeHelper->getThemeAssetUrl('images/pwa-icons/icon-192.png'); + $icon512 = $themeHelper->getThemeAssetUrl('images/pwa-icons/icon-512.png'); + + $icons = []; + + if (!empty($icon192)) { + $icons[] = [ + 'src' => $icon192, + 'sizes' => '192x192', + 'type' => 'image/png', + ]; + } + + if (!empty($icon512)) { + $icons[] = [ + 'src' => $icon512, + 'sizes' => '512x512', + 'type' => 'image/png', + ]; + } + + $data = [ + 'name' => 'Chamilo LMS', + 'short_name' => 'Chamilo', + 'start_url' => '/', + 'display' => 'standalone', + 'theme_color' => '#1b4fa0', + 'background_color' => '#ffffff', + 'orientation' => 'portrait-primary', + 'icons' => $icons, + ]; + + return $this->json($data); + } +} From a2bc4eb2205e39995ae18487337bffa78cd545a0 Mon Sep 17 00:00:00 2001 From: Yannick Warnier Date: Mon, 7 Jul 2025 23:57:53 +0200 Subject: [PATCH 12/13] Minor: Update language term --- src/CoreBundle/Controller/PushNotificationController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CoreBundle/Controller/PushNotificationController.php b/src/CoreBundle/Controller/PushNotificationController.php index e8c6bff1819..bd40677c95b 100644 --- a/src/CoreBundle/Controller/PushNotificationController.php +++ b/src/CoreBundle/Controller/PushNotificationController.php @@ -189,7 +189,7 @@ public function sendGotify(): JsonResponse $content = $response->toArray(false); return new JsonResponse([ - 'message' => $this->translator->trans('Gotify notification has been sent.'), + 'message' => $this->translator->trans('Notification sent to Gotify.'), 'status' => $statusCode, 'response' => $content, ]); From c5e8bdb09f970c113bfa7b81611f236a4dc028ea Mon Sep 17 00:00:00 2001 From: Yannick Warnier Date: Tue, 8 Jul 2025 00:07:55 +0200 Subject: [PATCH 13/13] Minor: Update quoting of setting comment to avoid double quotes escaping in Gettext --- src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php index 03e0e5dc824..17a83797be5 100644 --- a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php +++ b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php @@ -1039,7 +1039,7 @@ public static function getExistingSettings(): array [ '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.', + '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',