From 2ea8ae853dc51dbccc1d8df75128b0312ac83516 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 2 Feb 2026 18:26:35 +0100 Subject: [PATCH 1/7] feat: added Web Push API support (#3845) --- .docker/nginx/default.conf | 16 + composer.json | 3 +- composer.lock | 442 +++++++++++++++++- eslint.config.mjs | 18 + nginx.conf | 9 + phpmyfaq/admin/assets/src/api/index.ts | 1 + phpmyfaq/admin/assets/src/api/push.test.ts | 40 ++ phpmyfaq/admin/assets/src/api/push.ts | 34 ++ .../assets/src/configuration/configuration.ts | 4 + .../admin/assets/src/configuration/index.ts | 1 + .../admin/assets/src/configuration/webpush.ts | 96 ++++ phpmyfaq/assets/src/api/push.test.ts | 102 ++++ phpmyfaq/assets/src/api/push.ts | 117 +++++ phpmyfaq/assets/src/frontend.ts | 4 + phpmyfaq/assets/src/push/index.ts | 258 ++++++++++ .../templates/admin/configuration/main.twig | 7 + phpmyfaq/assets/templates/default/index.twig | 19 + phpmyfaq/assets/templates/default/ucp.twig | 38 ++ .../Administration/Api/FaqController.php | 21 + .../Administration/Api/PushController.php | 54 +++ .../Controller/Api/QuestionController.php | 3 +- .../Frontend/AbstractFrontController.php | 6 + .../Frontend/Api/PushController.php | 133 ++++++ .../Entity/PushSubscriptionEntity.php | 129 +++++ phpmyfaq/src/phpMyFAQ/Faq/Permission.php | 15 + phpmyfaq/src/phpMyFAQ/Notification.php | 34 ++ .../Push/PushSubscriptionRepository.php | 189 ++++++++ phpmyfaq/src/phpMyFAQ/Push/WebPushService.php | 157 +++++++ .../Setup/Installation/DatabaseSchema.php | 17 + .../Setup/Installation/DefaultDataSeeder.php | 4 + .../Migration/Versions/Migration420Alpha.php | 129 +++++ phpmyfaq/src/services.php | 12 + phpmyfaq/sw.js | 71 +++ phpmyfaq/translations/language_en.php | 21 + .../Entity/PushSubscriptionEntityTest.php | 133 ++++++ tests/phpMyFAQ/Faq/PermissionTest.php | 68 ++- .../Push/PushSubscriptionRepositoryTest.php | 141 ++++++ tests/phpMyFAQ/Push/WebPushServiceTest.php | 155 ++++++ .../Setup/Installation/DatabaseSchemaTest.php | 2 +- .../Installation/SchemaInstallerTest.php | 2 +- .../Setup/Migration/MigrationRegistryTest.php | 2 +- 41 files changed, 2679 insertions(+), 28 deletions(-) create mode 100644 phpmyfaq/admin/assets/src/api/push.test.ts create mode 100644 phpmyfaq/admin/assets/src/api/push.ts create mode 100644 phpmyfaq/admin/assets/src/configuration/webpush.ts create mode 100644 phpmyfaq/assets/src/api/push.test.ts create mode 100644 phpmyfaq/assets/src/api/push.ts create mode 100644 phpmyfaq/assets/src/push/index.ts create mode 100644 phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PushController.php create mode 100644 phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php create mode 100644 phpmyfaq/src/phpMyFAQ/Entity/PushSubscriptionEntity.php create mode 100644 phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php create mode 100644 phpmyfaq/src/phpMyFAQ/Push/WebPushService.php create mode 100644 phpmyfaq/sw.js create mode 100644 tests/phpMyFAQ/Entity/PushSubscriptionEntityTest.php create mode 100644 tests/phpMyFAQ/Push/PushSubscriptionRepositoryTest.php create mode 100644 tests/phpMyFAQ/Push/WebPushServiceTest.php diff --git a/.docker/nginx/default.conf b/.docker/nginx/default.conf index 0c80a5f5ea..1991d8e1f2 100644 --- a/.docker/nginx/default.conf +++ b/.docker/nginx/default.conf @@ -24,6 +24,14 @@ server { gzip_buffers 16 8k; gzip_disable "MSIE [1-6]\.(?!.*SV1)"; + # Service Worker - must be before other .js rules, no caching, proper headers + location ^~ /sw.js { + default_type application/javascript; + add_header Service-Worker-Allowed "/"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header X-Content-Type-Options "nosniff"; + } + location ~* \.(jpg|jpeg|gif|png|webp|svg|ico|mp4|webm|mpeg|ttf|otf|woff|woff2|css|js|pdf)$ { expires 1y; add_header Cache-Control "public"; @@ -150,6 +158,14 @@ server { gzip_buffers 16 8k; gzip_disable "MSIE [1-6]\.(?!.*SV1)"; + # Service Worker - must be before other .js rules, no caching, proper headers + location ^~ /sw.js { + default_type application/javascript; + add_header Service-Worker-Allowed "/"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header X-Content-Type-Options "nosniff"; + } + location ~* \.(jpg|jpeg|gif|png|webp|svg|ico|mp4|webm|mpeg|ttf|otf|woff|woff2|css|js|pdf)$ { expires 1y; add_header Cache-Control "public"; diff --git a/composer.json b/composer.json index ad97174fd1..c3fab398f1 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "endroid/qr-code": "^6.0.2", "guzzlehttp/guzzle": "^7.5", "league/commonmark": "^2.4", + "minishlink/web-push": "^9.0", "monolog/monolog": "^3.3", "myclabs/deep-copy": "~1.0", "opensearch-project/opensearch-php": "^2.4", @@ -39,8 +40,8 @@ "symfony/console": "^8.0", "symfony/dependency-injection": "^8.0", "symfony/dotenv": "^8.0", - "symfony/event-dispatcher": "^8.0", "symfony/error-handler": "^8.0", + "symfony/event-dispatcher": "^8.0", "symfony/html-sanitizer": "^8.0", "symfony/http-client": "^8.0", "symfony/http-foundation": "^8.0", diff --git a/composer.lock b/composer.lock index 681a78c042..84ed388fc1 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": "c7abdf8696e2c5fff495b949f660f1ef", + "content-hash": "8ef321ad1536036d20459d393797262a", "packages": [ { "name": "2tvenom/cborencode", @@ -106,6 +106,66 @@ }, "time": "2025-11-19T17:15:36+00:00" }, + { + "name": "brick/math", + "version": "0.14.3", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "6af96b11de3f7d99730c118c200418c48274edb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/6af96b11de3f7d99730c118c200418c48274edb4", + "reference": "6af96b11de3f7d99730c118c200418c48274edb4", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.3" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-01T15:18:05+00:00" + }, { "name": "dasprid/enum", "version": "1.0.7", @@ -1369,6 +1429,73 @@ ], "time": "2026-01-15T06:54:53+00:00" }, + { + "name": "minishlink/web-push", + "version": "v9.0.4", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f979f40b0017d2f86d82b9f21edbc515d031cc23", + "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.9.2", + "php": ">=8.1", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php82": "^v1.31.0", + "web-token/jwt-library": "^3.3.0|^4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.91.3", + "phpstan/phpstan": "^2.1.2", + "phpunit/phpunit": "^10.5.44|^11.5.6", + "symfony/polyfill-iconv": "^1.33" + }, + "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.4" + }, + "time": "2025-12-10T14:00:12+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -2318,6 +2445,54 @@ ], "time": "2026-01-27T09:17:28+00:00" }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -2829,6 +3004,180 @@ ], "time": "2026-01-05T13:17:41+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/pki-framework", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "f0e9a548df4e3942886adc9b7830581a46334631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/f0e9a548df4e3942886adc9b7830581a46334631", + "reference": "f0e9a548df4e3942886adc9b7830581a46334631", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", + "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|^0.31", + "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|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.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.4.1" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-12-20T12:57:40+00:00" + }, { "name": "symfony/config", "version": "v8.0.4", @@ -5877,6 +6226,95 @@ } ], "time": "2026-01-23T21:00:41+00:00" + }, + { + "name": "web-token/jwt-library", + "version": "4.1.3", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "690d4dd47b78f423cb90457f858e4106e1deb728" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/690d4dd47b78f423cb90457f858e4106e1deb728", + "reference": "690d4dd47b78f423cb90457f858e4106e1deb728", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14", + "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.1.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-12-18T14:27:35+00:00" } ], "packages-dev": [ @@ -8446,5 +8884,5 @@ "platform-overrides": { "php": "8.4.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/eslint.config.mjs b/eslint.config.mjs index e56bdf2663..3c93b3d2cd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,5 +18,23 @@ const ignoresConfig = globalIgnores([ export default defineConfig([ { extends: [ignoresConfig, eslint.configs.recommended, tseslint.configs.strict], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, + { + files: ['phpmyfaq/sw.js'], + languageOptions: { + globals: { + self: 'readonly', + }, + }, }, ]); diff --git a/nginx.conf b/nginx.conf index 2853222869..6993a4af48 100644 --- a/nginx.conf +++ b/nginx.conf @@ -79,6 +79,15 @@ server { rewrite ^ /404.html last; } + # Service Worker - no caching, proper headers + location = /sw.js { + add_header Content-Type "application/javascript; charset=utf-8"; + add_header Service-Worker-Allowed "/"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header X-Content-Type-Options "nosniff"; + try_files $uri =404; + } + location / { index index.php; try_files $uri $uri/ @rewriteapp; diff --git a/phpmyfaq/admin/assets/src/api/index.ts b/phpmyfaq/admin/assets/src/api/index.ts index c56f727169..69c3796f02 100644 --- a/phpmyfaq/admin/assets/src/api/index.ts +++ b/phpmyfaq/admin/assets/src/api/index.ts @@ -12,6 +12,7 @@ export * from './markdown'; export * from './media-browser'; export * from './news'; export * from './opensearch'; +export * from './push'; export * from './page'; export * from './question'; export * from './statistics'; diff --git a/phpmyfaq/admin/assets/src/api/push.test.ts b/phpmyfaq/admin/assets/src/api/push.test.ts new file mode 100644 index 0000000000..0f23d14005 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/push.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { fetchGenerateVapidKeys } from './push'; + +describe('Push API', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('fetchGenerateVapidKeys', () => { + it('should generate VAPID keys and return JSON response if successful', async () => { + const mockResponse = { success: true, publicKey: 'BPubKey123' }; + globalThis.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + const result = await fetchGenerateVapidKeys(); + + expect(result).toEqual(mockResponse); + expect(globalThis.fetch).toHaveBeenCalledWith('./api/push/generate-vapid-keys', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + it('should throw an error if fetch fails', async () => { + const mockError = new Error('Fetch failed'); + globalThis.fetch = vi.fn(() => Promise.reject(mockError)); + + await expect(fetchGenerateVapidKeys()).rejects.toThrow(mockError); + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/api/push.ts b/phpmyfaq/admin/assets/src/api/push.ts new file mode 100644 index 0000000000..d368e3942e --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/push.ts @@ -0,0 +1,34 @@ +/** + * Fetch data for Web Push configuration + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-04 + */ + +import { fetchJson } from './fetch-wrapper'; + +export interface VapidKeysResponse { + success: boolean; + publicKey: string; + error?: string; +} + +export const fetchGenerateVapidKeys = async (): Promise => { + return (await fetchJson('./api/push/generate-vapid-keys', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + })) as VapidKeysResponse; +}; diff --git a/phpmyfaq/admin/assets/src/configuration/configuration.ts b/phpmyfaq/admin/assets/src/configuration/configuration.ts index 887235c17f..f98591621d 100644 --- a/phpmyfaq/admin/assets/src/configuration/configuration.ts +++ b/phpmyfaq/admin/assets/src/configuration/configuration.ts @@ -29,6 +29,7 @@ import { fetchTranslationProvider, saveConfiguration, } from '../api'; +import { handleWebPush } from './webpush'; import { Response } from '../interfaces'; export const handleConfiguration = async (): Promise => { @@ -73,6 +74,9 @@ export const handleConfiguration = async (): Promise => { case '#translation': await handleTranslationProvider(); break; + case '#push': + await handleWebPush(); + break; } tabLoaded = true; diff --git a/phpmyfaq/admin/assets/src/configuration/index.ts b/phpmyfaq/admin/assets/src/configuration/index.ts index f3113ba55a..90902f5947 100644 --- a/phpmyfaq/admin/assets/src/configuration/index.ts +++ b/phpmyfaq/admin/assets/src/configuration/index.ts @@ -5,3 +5,4 @@ export * from './instance'; export * from './opensearch'; export * from './stopwords'; export * from './upgrade'; +export * from './webpush'; diff --git a/phpmyfaq/admin/assets/src/configuration/webpush.ts b/phpmyfaq/admin/assets/src/configuration/webpush.ts new file mode 100644 index 0000000000..876847b02d --- /dev/null +++ b/phpmyfaq/admin/assets/src/configuration/webpush.ts @@ -0,0 +1,96 @@ +/** + * Admin Web Push configuration + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-04 + */ + +import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; +import { fetchGenerateVapidKeys } from '../api'; + +export const handleWebPush = async (): Promise => { + const publicKeyInput = document.getElementById('edit[push.vapidPublicKey]') as HTMLInputElement | null; + const privateKeyInput = document.getElementById('edit[push.vapidPrivateKey]') as HTMLInputElement | null; + + if (!publicKeyInput) { + return; + } + + // Remove name attributes so VAPID keys are excluded from the config form submission. + // They are managed separately via the generate-vapid-keys API endpoint. + publicKeyInput.removeAttribute('name'); + privateKeyInput?.removeAttribute('name'); + + // Also remove VAPID key fields from the availableFields hidden input + // to prevent them from being processed during form save. + const availableFieldsInput = document.querySelector('input[name="availableFields"]'); + if (availableFieldsInput) { + try { + const fields: string[] = JSON.parse(availableFieldsInput.value); + const filtered = fields.filter( + (field: string) => field !== 'push.vapidPublicKey' && field !== 'push.vapidPrivateKey' + ); + availableFieldsInput.value = JSON.stringify(filtered); + } catch (_e) { + // Ignore parse errors + } + } + + // Mask the private key for display + if (privateKeyInput && privateKeyInput.value !== '') { + privateKeyInput.value = '\u2022'.repeat(20); + } + + const parentDiv = publicKeyInput.parentElement; + if (!parentDiv) { + return; + } + + // Avoid adding the button multiple times + if (parentDiv.querySelector('#pmf-generate-vapid-keys')) { + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.id = 'pmf-generate-vapid-keys'; + button.className = 'btn btn-outline-primary mt-2'; + button.innerHTML = ' Generate VAPID Keys'; + parentDiv.appendChild(button); + + button.addEventListener('click', async (event: Event): Promise => { + event.preventDefault(); + button.disabled = true; + button.innerHTML = + ' Generating...'; + + try { + const response = await fetchGenerateVapidKeys(); + + if (response.success) { + publicKeyInput.value = response.publicKey; + + if (privateKeyInput) { + privateKeyInput.value = '\u2022'.repeat(20); + } + + pushNotification('VAPID keys have been generated successfully.'); + } else { + pushErrorNotification(response.error ?? 'Failed to generate VAPID keys.'); + } + } catch (_error) { + pushErrorNotification('Failed to generate VAPID keys.'); + } finally { + button.disabled = false; + button.innerHTML = ' Generate VAPID Keys'; + } + }); +}; diff --git a/phpmyfaq/assets/src/api/push.test.ts b/phpmyfaq/assets/src/api/push.test.ts new file mode 100644 index 0000000000..c071b5fdb2 --- /dev/null +++ b/phpmyfaq/assets/src/api/push.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getVapidPublicKey, unsubscribePush, getPushStatus } from './push'; +import createFetchMock, { FetchMock } from 'vitest-fetch-mock'; +import type { VapidPublicKeyResponse, PushSubscribeResponse, PushStatusResponse } from './push'; + +const fetchMocker: FetchMock = createFetchMock(vi); + +fetchMocker.enableMocks(); + +describe('Push API', (): void => { + beforeEach((): void => { + fetchMocker.resetMocks(); + }); + + test('getVapidPublicKey should return key and enabled status when successful', async (): Promise => { + const mockResponse: VapidPublicKeyResponse = { + enabled: true, + vapidPublicKey: 'BNcR...testPublicKey', + }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await getVapidPublicKey(); + + expect(data).toEqual(mockResponse); + expect(data.enabled).toBe(true); + expect(data.vapidPublicKey).toBe('BNcR...testPublicKey'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/push/vapid-public-key', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('getVapidPublicKey should handle error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 500 }); + + await expect(getVapidPublicKey()).rejects.toThrow('HTTP 500'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('unsubscribePush should send endpoint and return success', async (): Promise => { + const mockResponse: PushSubscribeResponse = { success: true }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await unsubscribePush('https://fcm.googleapis.com/fcm/send/abc123'); + + expect(data).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/push/unsubscribe', { + method: 'POST', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: 'https://fcm.googleapis.com/fcm/send/abc123' }), + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('unsubscribePush should handle error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 401 }); + + await expect(unsubscribePush('https://example.com/push')).rejects.toThrow('HTTP 401'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('getPushStatus should return subscription status', async (): Promise => { + const mockResponse: PushStatusResponse = { subscribed: true }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await getPushStatus(); + + expect(data).toEqual(mockResponse); + expect(data.subscribed).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('api/push/status', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('getPushStatus should return false when not subscribed', async (): Promise => { + const mockResponse: PushStatusResponse = { subscribed: false }; + fetchMocker.mockResponseOnce(JSON.stringify(mockResponse)); + + const data = await getPushStatus(); + + expect(data.subscribed).toBe(false); + }); + + test('getPushStatus should handle auth error', async (): Promise => { + fetchMocker.mockResponseOnce(null, { status: 401 }); + + await expect(getPushStatus()).rejects.toThrow('HTTP 401'); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/phpmyfaq/assets/src/api/push.ts b/phpmyfaq/assets/src/api/push.ts new file mode 100644 index 0000000000..4058165ace --- /dev/null +++ b/phpmyfaq/assets/src/api/push.ts @@ -0,0 +1,117 @@ +/** + * Push notification API module + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-02 + */ + +export interface VapidPublicKeyResponse { + enabled: boolean; + vapidPublicKey: string; +} + +export interface PushSubscribeResponse { + success?: boolean; + error?: string; +} + +export interface PushStatusResponse { + subscribed: boolean; +} + +export const getVapidPublicKey = async (): Promise => { + const response: Response = await fetch('api/push/vapid-public-key', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const subscribePush = async (subscription: PushSubscription): Promise => { + const key = subscription.getKey('p256dh'); + const auth = subscription.getKey('auth'); + + if (!key || !auth) { + throw new Error('Missing subscription keys'); + } + + const publicKey = btoa(String.fromCharCode(...new Uint8Array(key))); + const authToken = btoa(String.fromCharCode(...new Uint8Array(auth))); + + const response: Response = await fetch('api/push/subscribe', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + endpoint: subscription.endpoint, + publicKey: publicKey, + authToken: authToken, + contentEncoding: (PushManager.supportedContentEncodings || ['aesgcm'])[0], + }), + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const unsubscribePush = async (endpoint: string): Promise => { + const response: Response = await fetch('api/push/unsubscribe', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ endpoint }), + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; + +export const getPushStatus = async (): Promise => { + const response: Response = await fetch('api/push/status', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); +}; diff --git a/phpmyfaq/assets/src/frontend.ts b/phpmyfaq/assets/src/frontend.ts index 8979ca4d1a..fe3f854d69 100644 --- a/phpmyfaq/assets/src/frontend.ts +++ b/phpmyfaq/assets/src/frontend.ts @@ -38,6 +38,7 @@ import { calculateReadingTime, handlePasswordStrength, handlePasswordToggle, han import './utils/tooltip'; import { handleWebAuthn } from './webauthn/webauthn'; import { handleChat } from './chat'; +import { handlePushNotifications } from './push'; document.addEventListener('DOMContentLoaded', (): void => { // Reload Captchas @@ -112,4 +113,7 @@ document.addEventListener('DOMContentLoaded', (): void => { // Handle Chat handleChat(); + + // Handle Push Notifications + handlePushNotifications(); }); diff --git a/phpmyfaq/assets/src/push/index.ts b/phpmyfaq/assets/src/push/index.ts new file mode 100644 index 0000000000..8f18d4f040 --- /dev/null +++ b/phpmyfaq/assets/src/push/index.ts @@ -0,0 +1,258 @@ +/** + * Push notification handler + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-02 + */ + +import { getVapidPublicKey, subscribePush, unsubscribePush } from '../api/push'; + +const PUSH_DISMISSED_KEY = 'pmf-push-banner-dismissed'; + +const urlBase64ToUint8Array = (base64String: string): Uint8Array => { + 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; +}; + +const updateButtonState = (button: HTMLButtonElement, subscribed: boolean): void => { + if (subscribed) { + button.textContent = button.dataset.labelDisable || 'Disable push notifications'; + button.classList.remove('btn-primary'); + button.classList.add('btn-outline-secondary'); + } else { + button.textContent = button.dataset.labelEnable || 'Enable push notifications'; + button.classList.remove('btn-outline-secondary'); + button.classList.add('btn-primary'); + } +}; + +const showToast = (message: string, type: 'success' | 'danger'): void => { + const container = document.getElementById('pmf-push-toast-container'); + if (!container) { + return; + } + + const alert = document.createElement('div'); + alert.className = `alert alert-${type} alert-dismissible fade show`; + alert.role = 'alert'; + alert.innerHTML = `${message}`; + container.appendChild(alert); + + setTimeout(() => { + alert.classList.remove('show'); + setTimeout(() => alert.remove(), 150); + }, 4000); +}; + +const subscribeUser = async ( + registration: ServiceWorkerRegistration, + vapidPublicKey: string +): Promise => { + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }); + + await subscribePush(subscription); + return subscription; +}; + +/** + * Handles the global push notification banner shown on all pages. + */ +const handlePushBanner = async ( + registration: ServiceWorkerRegistration, + vapidPublicKey: string, + isSubscribed: boolean +): Promise => { + const banner = document.getElementById('pmf-push-banner'); + if (!banner || isSubscribed) { + return; + } + + if (!('Notification' in window) || Notification.permission === 'denied') { + return; + } + + if (Notification.permission === 'granted') { + // Already granted but not subscribed — might have been cleared. Don't nag. + return; + } + + // Check if the user has dismissed the banner before + if (localStorage.getItem(PUSH_DISMISSED_KEY)) { + return; + } + + // Show the banner + banner.classList.remove('d-none'); + + const enableButton = document.getElementById('pmf-push-banner-enable') as HTMLButtonElement | null; + const dismissButton = document.getElementById('pmf-push-banner-dismiss') as HTMLButtonElement | null; + + enableButton?.addEventListener('click', async (): Promise => { + try { + await subscribeUser(registration, vapidPublicKey); + banner.classList.add('d-none'); + localStorage.setItem(PUSH_DISMISSED_KEY, 'subscribed'); + } catch (error) { + console.error('Push subscription error:', error); + banner.classList.add('d-none'); + localStorage.setItem(PUSH_DISMISSED_KEY, 'denied'); + } + }); + + dismissButton?.addEventListener('click', (): void => { + banner.classList.add('d-none'); + localStorage.setItem(PUSH_DISMISSED_KEY, 'dismissed'); + }); +}; + +/** + * Handles the UCP push notification toggle button. + */ +const handleUcpToggle = async ( + button: HTMLButtonElement, + registration: ServiceWorkerRegistration, + vapidPublicKey: string, + initialSubscription: PushSubscription | null +): Promise => { + let isSubscribed = initialSubscription !== null; + let currentSubscription = initialSubscription; + updateButtonState(button, isSubscribed); + button.disabled = false; + + button.addEventListener('click', async (): Promise => { + button.disabled = true; + + try { + if (isSubscribed && currentSubscription) { + const endpoint = currentSubscription.endpoint; + await currentSubscription.unsubscribe(); + await unsubscribePush(endpoint); + isSubscribed = false; + currentSubscription = null; + updateButtonState(button, false); + showToast(button.dataset.msgDisabled || 'Push notifications disabled', 'success'); + localStorage.removeItem(PUSH_DISMISSED_KEY); + } else { + if (Notification.permission === 'denied') { + showToast(button.dataset.msgPermissionDenied || 'Push notification permission was denied', 'danger'); + button.disabled = false; + return; + } + + currentSubscription = await subscribeUser(registration, vapidPublicKey); + isSubscribed = true; + updateButtonState(button, true); + showToast(button.dataset.msgEnabled || 'Push notifications enabled', 'success'); + localStorage.setItem(PUSH_DISMISSED_KEY, 'subscribed'); + } + } catch (error) { + console.error('Push subscription error:', error); + // Show more specific error message + let errorMessage = button.dataset.msgError || 'Failed to enable push notifications'; + if (error instanceof Error) { + if (error.message.includes('permission') || error.name === 'NotAllowedError') { + errorMessage = button.dataset.msgPermissionDenied || 'Push notification permission was denied'; + } else { + errorMessage = error.message; + } + } + showToast(errorMessage, 'danger'); + } + + button.disabled = false; + }); +}; + +export const handlePushNotifications = async (): Promise => { + const ucpButton = document.getElementById('pmf-push-toggle') as HTMLButtonElement | null; + const banner = document.getElementById('pmf-push-banner'); + + // Nothing to do if neither the UCP button nor the global banner exist + if (!ucpButton && !banner) { + return; + } + + // For the banner only (no UCP button): skip API call if user already dismissed/subscribed + // This avoids unnecessary API calls on every page load + if (!ucpButton && banner) { + if (localStorage.getItem(PUSH_DISMISSED_KEY)) { + return; + } + // Also skip if notifications are already granted (user is subscribed) + if ('Notification' in window && Notification.permission === 'granted') { + return; + } + // Skip if permission was denied + if ('Notification' in window && Notification.permission === 'denied') { + return; + } + } + + // Service workers require a secure context (HTTPS or localhost) + if (!window.isSecureContext) { + banner?.classList.add('d-none'); + return; + } + + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + if (ucpButton) { + ucpButton.disabled = true; + ucpButton.textContent = + ucpButton.dataset.labelNotSupported || 'Push notifications are not supported by your browser'; + } + banner?.classList.add('d-none'); + return; + } + + try { + const vapidResponse = await getVapidPublicKey(); + + if (!vapidResponse.enabled || !vapidResponse.vapidPublicKey) { + if (ucpButton) { + ucpButton.parentElement?.parentElement?.classList.add('d-none'); + } + banner?.classList.add('d-none'); + return; + } + + // Register service worker - use absolute path from site root + const registration = await navigator.serviceWorker.register('/sw.js'); + const existingSubscription = await registration.pushManager.getSubscription(); + const isSubscribed = existingSubscription !== null; + + // Handle UCP toggle button (on the User Control Panel page) + if (ucpButton) { + await handleUcpToggle(ucpButton, registration, vapidResponse.vapidPublicKey, existingSubscription); + } + + // Handle global push banner (on all pages) + if (banner) { + await handlePushBanner(registration, vapidResponse.vapidPublicKey, isSubscribed); + } + } catch (error) { + console.error('Push notification setup failed:', error); + if (ucpButton) { + ucpButton.disabled = true; + } + banner?.classList.add('d-none'); + } +}; diff --git a/phpmyfaq/assets/templates/admin/configuration/main.twig b/phpmyfaq/assets/templates/admin/configuration/main.twig index 0ebc52564c..a5de83dd88 100644 --- a/phpmyfaq/assets/templates/admin/configuration/main.twig +++ b/phpmyfaq/assets/templates/admin/configuration/main.twig @@ -95,6 +95,12 @@ {{ 'msgTranslation' | translate }} +