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/CHANGELOG.md b/CHANGELOG.md index 219b0e8cc3..a65f0549ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This is a log of major user-visible changes in each phpMyFAQ release. - added support for custom pages with WYSIWYG editor, SEO features, multi-language support, and search integration (Thorsten) - added a translation adapter system with support for Google Cloud Translation, DeepL, Azure Translator, Amazon Translate, and LibreTranslate (Thorsten) - added a simple chat for users (Thorsten) +- added push notifications via Web Push API (Thorsten) - added support for Flesch readability tests (Thorsten) - improved audit and activity log with comprehensive security event tracking (Thorsten) - improved API errors with formatted RFC 7807 Problem Details JSON responses (Thorsten) diff --git a/composer.json b/composer.json index ad97174fd1..c614a59a4d 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,8 @@ "ext-filter": "*", "ext-gd": "*", "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", "ext-sodium": "*", "ext-xml": "*", "ext-xmlwriter": "*", @@ -30,6 +32,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 +42,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", @@ -65,9 +68,10 @@ "zircote/swagger-php": "^6.0" }, "suggest": { + "ext-bcmath": "*", "ext-frankenphp": "*", + "ext-gmp": "*", "ext-ldap": "*", - "ext-openssl": "*", "ext-pdo": "*", "ext-pgsql": "*", "ext-sqlite3": "*", diff --git a/composer.lock b/composer.lock index 681a78c042..cbc57fa4a6 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": "38e876dc785d2bc5dd2a7c09494aa92a", "packages": [ { "name": "2tvenom/cborencode", @@ -106,6 +106,66 @@ }, "time": "2025-11-19T17:15:36+00:00" }, + { + "name": "brick/math", + "version": "0.14.6", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", + "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", + "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.6" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-05T07:59:58+00:00" + }, { "name": "dasprid/enum", "version": "1.0.7", @@ -492,16 +552,16 @@ }, { "name": "endroid/qr-code", - "version": "6.1.0", + "version": "6.1.3", "source": { "type": "git", "url": "https://github.com/endroid/qr-code.git", - "reference": "5a74873ba8873ddcc557d22755c914ac899563ae" + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/endroid/qr-code/zipball/5a74873ba8873ddcc557d22755c914ac899563ae", - "reference": "5a74873ba8873ddcc557d22755c914ac899563ae", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", "shasum": "" }, "require": { @@ -552,7 +612,7 @@ ], "support": { "issues": "https://github.com/endroid/qr-code/issues", - "source": "https://github.com/endroid/qr-code/tree/6.1.0" + "source": "https://github.com/endroid/qr-code/tree/6.1.3" }, "funding": [ { @@ -560,7 +620,7 @@ "type": "github" } ], - "time": "2025-11-25T10:47:45+00:00" + "time": "2026-02-05T07:01:58+00:00" }, { "name": "ezimuel/guzzlestreams", @@ -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", @@ -1599,16 +1726,16 @@ }, { "name": "nette/utils", - "version": "v4.1.1", + "version": "v4.1.2", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", "shasum": "" }, "require": { @@ -1621,7 +1748,7 @@ "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -1682,22 +1809,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.1" + "source": "https://github.com/nette/utils/tree/v4.1.2" }, - "time": "2025-12-22T12:14:32+00:00" + "time": "2026-02-03T17:21:09+00:00" }, { "name": "open-telemetry/api", - "version": "1.7.1", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4" + "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", - "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/df5197c6fd0ddd8e9883b87de042d9341300e2ad", + "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad", "shasum": "" }, "require": { @@ -1707,7 +1834,7 @@ "symfony/polyfill-php82": "^1.26" }, "conflict": { - "open-telemetry/sdk": "<=1.0.8" + "open-telemetry/sdk": "<=1.11" }, "type": "library", "extra": { @@ -1754,7 +1881,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-19T10:49:48+00:00" + "time": "2026-01-21T04:14:03+00:00" }, { "name": "open-telemetry/context", @@ -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,21 +6226,110 @@ } ], "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": [ { "name": "carthage-software/mago", - "version": "1.4.1", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/carthage-software/mago.git", - "reference": "5429336f3c14b7a70678a84ce4c04e304029aa43" + "reference": "0ce3f5d230f8ae330617b04299700347d3ca6806" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/carthage-software/mago/zipball/5429336f3c14b7a70678a84ce4c04e304029aa43", - "reference": "5429336f3c14b7a70678a84ce4c04e304029aa43", + "url": "https://api.github.com/repos/carthage-software/mago/zipball/0ce3f5d230f8ae330617b04299700347d3ca6806", + "reference": "0ce3f5d230f8ae330617b04299700347d3ca6806", "shasum": "" }, "require": { @@ -5929,7 +6367,7 @@ ], "support": { "issues": "https://github.com/carthage-software/mago/issues", - "source": "https://github.com/carthage-software/mago/tree/1.4.1" + "source": "https://github.com/carthage-software/mago/tree/1.5.0" }, "funding": [ { @@ -5937,7 +6375,7 @@ "type": "github" } ], - "time": "2026-02-03T19:31:16+00:00" + "time": "2026-02-04T21:37:39+00:00" }, { "name": "doctrine/deprecations", @@ -6509,11 +6947,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.37", + "version": "2.1.38", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/28cd424c5ea984128c95cfa7ea658808e8954e49", - "reference": "28cd424c5ea984128c95cfa7ea658808e8954e49", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", + "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", "shasum": "" }, "require": { @@ -6558,20 +6996,20 @@ "type": "github" } ], - "time": "2026-01-24T08:21:55+00:00" + "time": "2026-01-30T17:12:46+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -6627,7 +7065,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -6647,20 +7085,20 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -6700,15 +7138,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -6896,16 +7346,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "12.5.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "83d4c158526c879b4c5cf7149d27958b6d912373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/83d4c158526c879b4c5cf7149d27958b6d912373", + "reference": "83d4c158526c879b4c5cf7149d27958b6d912373", "shasum": "" }, "require": { @@ -6920,7 +7370,7 @@ "phar-io/version": "^3.2.1", "php": ">=8.3", "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", @@ -6931,6 +7381,7 @@ "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -6973,7 +7424,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.9" }, "funding": [ { @@ -6997,7 +7448,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-02-05T08:01:09+00:00" }, { "name": "radebatz/type-info-extras", @@ -8437,6 +8888,8 @@ "ext-filter": "*", "ext-gd": "*", "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", "ext-sodium": "*", "ext-xml": "*", "ext-xmlwriter": "*", @@ -8446,5 +8899,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..8b6c4c5979 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/push.test.ts @@ -0,0 +1,41 @@ +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('csrf-token'); + + expect(result).toEqual(mockResponse); + expect(globalThis.fetch).toHaveBeenCalledWith('./api/push/generate-vapid-keys', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ csrf: 'csrf-token' }), + 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('csrf-token')).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..f28c8728d0 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/push.ts @@ -0,0 +1,35 @@ +/** + * 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 (csrfToken: string): Promise => { + return (await fetchJson('./api/push/generate-vapid-keys', { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ csrf: csrfToken }), + })) 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..040f5be4ff --- /dev/null +++ b/phpmyfaq/admin/assets/src/configuration/webpush.ts @@ -0,0 +1,97 @@ +/** + * 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 csrfToken = (document.getElementById('pmf-csrf-token') as HTMLInputElement)?.value ?? ''; + const response = await fetchGenerateVapidKeys(csrfToken); + + 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..f0932a06ff --- /dev/null +++ b/phpmyfaq/assets/src/push/index.ts @@ -0,0 +1,279 @@ +/** + * 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'; +import { addElement } from '../utils'; + +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); + + // Use an explicit ArrayBuffer so TypeScript knows the backing store is an ArrayBuffer (not ArrayBufferLike) + const buffer = new ArrayBuffer(rawData.length); + const outputArray = new Uint8Array(buffer); + + 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 messageNode = document.createTextNode(message); + + const closeButton = addElement('button', { + type: 'button', + className: 'btn-close', + 'data-bs-dismiss': 'alert', + ariaLabel: 'Close', + }); + + const alert = addElement( + 'div', + { + className: `alert alert-${type} alert-dismissible fade show`, + role: 'alert', + }, + [messageNode, closeButton] + ); + + 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) as unknown as BufferSource, + }); + + 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 - compute URL from base href to support subdirectory installs + const baseElement = document.querySelector('base'); + const baseHref = baseElement?.getAttribute('href') ?? '/'; + const swUrl = new URL('sw.js', baseHref).href; + const registration = await navigator.serviceWorker.register(swUrl); + 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 }} +