diff --git a/README.md b/README.md index 0f0007e..3daf4a2 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,43 @@ else { } ``` +## Redis session storage + +This package now includes `Gt\Session\RedisHandler` for shared session storage. +It works with Redis-compatible backends such as Redis and Valkey, and is intended +for deployments where application nodes are disposable and session state needs to +survive traffic moving between servers. + +`RedisHandler` expects `save_path` to be a DSN rather than a filesystem path. +It uses the `phpredis` extension at runtime. + +Example production config: + +```ini +[session] +handler=Gt\Session\RedisHandler +save_path=rediss://default:secret@example-redis.internal:25061/0?prefix=GT:&ttl=1440 +name=GT +use_cookies=true +``` + +Supported DSN forms: + +- `redis://host:6379` +- `redis://:password@host:6379/0` +- `redis://username:password@host:6379/0` +- `rediss://username:password@host:6379/0` + +Useful query parameters: + +- `prefix`: key prefix for stored sessions, defaults to `:` +- `ttl`: session lifetime in seconds, defaults to `session.gc_maxlifetime` +- `timeout`: connection timeout in seconds +- `read_timeout`: socket read timeout in seconds +- `persistent=1`: enable persistent connections +- `persistent_id`: optional persistent connection pool id +- `verify_peer=0` / `verify_peer_name=0`: optional TLS verification flags + # Proudly sponsored by [JetBrains Open Source sponsorship program](https://www.jetbrains.com/community/opensource/) diff --git a/composer.json b/composer.json index 48ab401..78112b0 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,9 @@ "php": ">=8.2", "phpgt/typesafegetter": "^1.3" }, + "suggest": { + "ext-redis": "Required to use Gt\\Session\\RedisHandler." + }, "require-dev": { "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.1", @@ -14,6 +17,20 @@ "squizlabs/php_codesniffer": "^3.7" }, + "scripts": { + "phpunit": "vendor/bin/phpunit --configuration phpunit.xml", + "phpunit:coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --configuration phpunit.xml --coverage-text", + "phpstan": "vendor/bin/phpstan analyse --level 6 src", + "phpcs": "vendor/bin/phpcs src --standard=phpcs.xml", + "phpmd": "vendor/bin/phpmd src/ text phpmd.xml", + "test": [ + "@phpunit", + "@phpstan", + "@phpcs", + "@phpmd" + ] + }, + "autoload": { "psr-4": { "Gt\\Session\\": "./src" diff --git a/composer.lock b/composer.lock index 49e9755..42fa268 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "90f742569783bb1b522aa97fe7d3d045", + "content-hash": "5bf8b129b89ebdbd2f4b53a66a2b077e", "packages": [ { "name": "phpgt/typesafegetter", - "version": "v1.3.2", + "version": "v1.3.3", "source": { "type": "git", - "url": "https://github.com/PhpGt/TypeSafeGetter.git", - "reference": "f760c05a37b1cc188dcbf800c5fdfab8a926b4b0" + "url": "https://github.com/phpgt/TypeSafeGetter.git", + "reference": "a0d339103828791989cbb81f760d252f3c2f8b8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PhpGt/TypeSafeGetter/zipball/f760c05a37b1cc188dcbf800c5fdfab8a926b4b0", - "reference": "f760c05a37b1cc188dcbf800c5fdfab8a926b4b0", + "url": "https://api.github.com/repos/phpgt/TypeSafeGetter/zipball/a0d339103828791989cbb81f760d252f3c2f8b8c", + "reference": "a0d339103828791989cbb81f760d252f3c2f8b8c", "shasum": "" }, "require": { @@ -47,8 +47,8 @@ ], "description": "An interface for objects that expose type-safe getter methods.", "support": { - "issues": "https://github.com/PhpGt/TypeSafeGetter/issues", - "source": "https://github.com/PhpGt/TypeSafeGetter/tree/v1.3.2" + "issues": "https://github.com/phpgt/TypeSafeGetter/issues", + "source": "https://github.com/phpgt/TypeSafeGetter/tree/v1.3.3" }, "funding": [ { @@ -56,7 +56,7 @@ "type": "github" } ], - "time": "2023-04-28T14:42:27+00:00" + "time": "2026-03-10T22:28:01+00:00" } ], "packages-dev": [ @@ -207,16 +207,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.3", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -255,7 +255,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -263,20 +263,20 @@ "type": "tidelift" } ], - "time": "2025-07-05T12:25:42+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.0", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", - "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -295,7 +295,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -319,9 +319,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-07-27T20:03:57+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "pdepend/pdepend", @@ -589,16 +589,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.21", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6" - }, + "version": "2.1.40", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1ccf445757458c06a04eb3f803603cb118fe5fa6", - "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", "shasum": "" }, "require": { @@ -643,7 +638,7 @@ "type": "github" } ], - "time": "2025-07-28T19:35:08+00:00" + "time": "2026-02-23T15:04:35+00:00" }, { "name": "phpunit/php-code-coverage", @@ -968,16 +963,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.48", + "version": "10.5.63", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "6e0a2bc39f6fae7617989d690d76c48e6d2eb541" + "reference": "33198268dad71e926626b618f3ec3966661e4d90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6e0a2bc39f6fae7617989d690d76c48e6d2eb541", - "reference": "6e0a2bc39f6fae7617989d690d76c48e6d2eb541", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", "shasum": "" }, "require": { @@ -987,7 +982,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.3", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -998,13 +993,13 @@ "phpunit/php-timer": "^6.0.0", "sebastian/cli-parser": "^2.0.1", "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.3", + "sebastian/comparator": "^5.0.5", "sebastian/diff": "^5.1.1", "sebastian/environment": "^6.1.0", - "sebastian/exporter": "^5.1.2", + "sebastian/exporter": "^5.1.4", "sebastian/global-state": "^6.0.2", "sebastian/object-enumerator": "^5.0.0", - "sebastian/recursion-context": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", "sebastian/type": "^4.0.0", "sebastian/version": "^4.0.1" }, @@ -1049,7 +1044,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.48" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" }, "funding": [ { @@ -1073,7 +1068,7 @@ "type": "tidelift" } ], - "time": "2025-07-11T04:07:17+00:00" + "time": "2026-01-27T05:48:37+00:00" }, { "name": "psr/container", @@ -1348,16 +1343,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.3", + "version": "5.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e" + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", "shasum": "" }, "require": { @@ -1413,15 +1408,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" }, "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/sebastian/comparator", + "type": "tidelift" } ], - "time": "2024-10-18T14:56:07+00:00" + "time": "2026-01-24T09:25:16+00:00" }, { "name": "sebastian/complexity", @@ -1614,16 +1621,16 @@ }, { "name": "sebastian/exporter", - "version": "5.1.2", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf" + "reference": "0735b90f4da94969541dac1da743446e276defa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/955288482d97c19a372d3f31006ab3f37da47adf", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", "shasum": "" }, "require": { @@ -1632,7 +1639,7 @@ "sebastian/recursion-context": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -1680,15 +1687,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" }, "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/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T07:17:12+00:00" + "time": "2025-09-24T06:09:11+00:00" }, { "name": "sebastian/global-state", @@ -1924,23 +1943,23 @@ }, { "name": "sebastian/recursion-context", - "version": "5.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712" + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", "shasum": "" }, "require": { "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -1975,15 +1994,28 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.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/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T07:05:40+00:00" + "time": "2025-08-10T07:50:56+00:00" }, { "name": "sebastian/type", @@ -2096,16 +2128,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.2", + "version": "3.13.5", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", "shasum": "" }, "require": { @@ -2122,11 +2154,6 @@ "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -2176,26 +2203,26 @@ "type": "thanks_dev" } ], - "time": "2025-06-17T22:17:01+00:00" + "time": "2025-11-04T16:30:35+00:00" }, { "name": "symfony/config", - "version": "v7.3.2", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "faef36e271bbeb74a9d733be4b56419b157762e2" + "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/faef36e271bbeb74a9d733be4b56419b157762e2", - "reference": "faef36e271bbeb74a9d733be4b56419b157762e2", + "url": "https://api.github.com/repos/symfony/config/zipball/6c17162555bfb58957a55bb0e43e00035b6ae3d5", + "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^7.1", + "symfony/filesystem": "^7.1|^8.0", "symfony/polyfill-ctype": "~1.8" }, "conflict": { @@ -2203,11 +2230,11 @@ "symfony/service-contracts": "<2.5" }, "require-dev": { - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2235,7 +2262,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.3.2" + "source": "https://github.com/symfony/config/tree/v7.4.7" }, "funding": [ { @@ -2255,28 +2282,28 @@ "type": "tidelift" } ], - "time": "2025-07-26T13:55:06+00:00" + "time": "2026-03-06T10:41:14+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.3.2", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "6cd2a1a77e8a0676a26e8bcddf10acfe7b0ba352" + "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/6cd2a1a77e8a0676a26e8bcddf10acfe7b0ba352", - "reference": "6cd2a1a77e8a0676a26e8bcddf10acfe7b0ba352", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", + "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", "shasum": "" }, "require": { "php": ">=8.2", "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^3.5", - "symfony/var-exporter": "^6.4.20|^7.2.5" + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" }, "conflict": { "ext-psr": "<1.1|>=2", @@ -2289,9 +2316,9 @@ "symfony/service-implementation": "1.1|2.0|3.0" }, "require-dev": { - "symfony/config": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/yaml": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2319,7 +2346,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.3.2" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.7" }, "funding": [ { @@ -2339,7 +2366,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:31:46+00:00" + "time": "2026-03-03T07:48:48+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2410,16 +2437,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.3.2", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", "shasum": "" }, "require": { @@ -2428,7 +2455,7 @@ "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2456,7 +2483,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.2" + "source": "https://github.com/symfony/filesystem/tree/v7.4.6" }, "funding": [ { @@ -2476,11 +2503,11 @@ "type": "tidelift" } ], - "time": "2025-07-07T08:17:47+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -2539,7 +2566,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -2550,6 +2577,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -2559,7 +2590,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2620,7 +2651,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -2631,6 +2662,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -2640,16 +2675,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -2703,7 +2738,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -2714,25 +2749,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "05b3e90654c097817325d6abd284f7938b05f467" + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/05b3e90654c097817325d6abd284f7938b05f467", - "reference": "05b3e90654c097817325d6abd284f7938b05f467", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", "shasum": "" }, "require": { @@ -2740,9 +2779,9 @@ "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2780,7 +2819,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.3.2" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" }, "funding": [ { @@ -2800,20 +2839,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-09-11T10:15:23+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -2842,7 +2881,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -2850,7 +2889,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], @@ -2859,8 +2898,8 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.1" + "php": ">=8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/phpcs.xml b/phpcs.xml index ad96b86..2d2aff4 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -19,7 +19,11 @@ - + + + + + diff --git a/phpmd.xml b/phpmd.xml index c30e0b4..d125c4d 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -25,7 +25,16 @@ - + + + + + + + + + + diff --git a/src/Flash.php b/src/Flash.php index 0e1e7c6..69c3aa4 100644 --- a/src/Flash.php +++ b/src/Flash.php @@ -5,7 +5,7 @@ use SplQueue; class Flash { - public function __construct(private readonly SessionStore $session) {} + public function __construct(private readonly SessionStoreInterface $session) {} public function put(string $name, string $message):void { $queue = $this->session->get("queue.$name"); diff --git a/src/RedisHandler.php b/src/RedisHandler.php new file mode 100644 index 0000000..9bda88a --- /dev/null +++ b/src/RedisHandler.php @@ -0,0 +1,222 @@ +parseSavePath($savePath, $name); + $client = $this->createClient(); + + $connected = $client->connect( + $config["host"], + $config["port"], + $config["timeout"], + $config["persistentId"], + 0, + $config["readTimeout"], + $config["context"], + ); + + if(!$connected) { + return false; + } + + if($config["auth"] !== null && !$client->auth($config["auth"])) { + return false; + } + + if($config["database"] > 0 && !$client->select($config["database"])) { + return false; + } + + $this->client = $client; + $this->prefix = $config["prefix"]; + $this->ttl = $config["ttl"]; + return true; + } + + public function close():bool { + if(is_null($this->client)) { + return true; + } + + return $this->client->close(); + } + + public function read(string $sessionId):string { + $value = $this->requireClient()->get($this->getKey($sessionId)); + return is_string($value) ? $value : ""; + } + + public function write(string $sessionId, string $sessionData):bool { + $client = $this->requireClient(); + $key = $this->getKey($sessionId); + + if($this->ttl > 0) { + return $client->setEx($key, $this->ttl, $sessionData); + } + + return $client->set($key, $sessionData); + } + + public function destroy(string $id = ""):bool { + return $this->requireClient()->del($this->getKey($id)) >= 0; + } + + // phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass + public function gc(int $maxLifeTime):int|false { + return 0; + } + // phpcs:enable + + /** + * @return array{ + * host:string, + * port:int, + * timeout:float, + * readTimeout:float, + * persistentId:?string, + * prefix:string, + * ttl:int, + * database:int, + * auth:array{string,string}|string|null, + * context:array{stream:array{verify_peer:bool,verify_peer_name:bool}}|null + * } + */ + private function parseSavePath(string $savePath, string $name):array { + $parts = parse_url($savePath); + if($parts === false || !isset($parts["host"])) { + throw new RuntimeException("Invalid Redis save_path DSN."); + } + + parse_str($parts["query"] ?? "", $query); + + [$host, $context] = $this->parseHostAndContext($parts, $query); + + return [ + "host" => $host, + "port" => (int)($parts["port"] ?? self::DEFAULT_PORT), + "timeout" => (float)($query["timeout"] ?? 0), + "readTimeout" => (float)($query["read_timeout"] ?? 0), + "persistentId" => $this->parsePersistentId($query), + "prefix" => $this->parsePrefix($query, $name), + "ttl" => (int)($query["ttl"] ?? ini_get("session.gc_maxlifetime")), + "database" => $this->parseDatabase($parts), + "auth" => $this->parseAuth($parts), + "context" => $context, + ]; + } + + /** + * @param array $parts + * @param array $query + * @return array{ + * 0:string, + * 1:array{stream:array{verify_peer:bool,verify_peer_name:bool}}|null + * } + */ + private function parseHostAndContext(array $parts, array $query):array { + $scheme = strtolower($parts["scheme"] ?? "redis"); + $host = $parts["host"]; + + if(!in_array($scheme, ["rediss", "tls"], true)) { + return [$host, null]; + } + + return [ + "tls://$host", + [ + "stream" => [ + "verify_peer" => filter_var( + $query["verify_peer"] ?? true, + FILTER_VALIDATE_BOOL + ), + "verify_peer_name" => filter_var( + $query["verify_peer_name"] ?? true, + FILTER_VALIDATE_BOOL + ), + ], + ], + ]; + } + + /** + * @param array $parts + * @return array{string,string}|string|null + */ + private function parseAuth(array $parts):array|string|null { + if(isset($parts["user"]) && $parts["user"] !== "" && isset($parts["pass"])) { + return [rawurldecode($parts["user"]), rawurldecode($parts["pass"])]; + } + + if(isset($parts["pass"])) { + return rawurldecode($parts["pass"]); + } + + return null; + } + + /** + * @param array $query + */ + private function parsePersistentId(array $query):?string { + if( + !isset($query["persistent"]) + || !filter_var($query["persistent"], FILTER_VALIDATE_BOOL) + ) { + return null; + } + + return is_string($query["persistent_id"] ?? null) + ? $query["persistent_id"] + : "phpgt-session"; + } + + /** + * @param array $query + */ + private function parsePrefix(array $query, string $name):string { + return is_string($query["prefix"] ?? null) + ? $query["prefix"] + : $name . self::DEFAULT_PREFIX_SEPARATOR; + } + + /** + * @param array $parts + */ + private function parseDatabase(array $parts):int { + return isset($parts["path"]) + ? (int)trim($parts["path"], "/") + : 0; + } + + private function getKey(string $sessionId):string { + return $this->prefix . $sessionId; + } + + protected function createClient():Redis { + if(!class_exists(Redis::class)) { + throw new RuntimeException( + "The phpredis extension is required to use Gt\\Session\\RedisHandler." + ); + } + + return new Redis(); + } + + private function requireClient():Redis { + if(is_null($this->client)) { + throw new RuntimeException("RedisHandler::open() must be called before use."); + } + + return $this->client; + } +} diff --git a/src/Session.php b/src/Session.php index f33ff6d..7ae2a8b 100644 --- a/src/Session.php +++ b/src/Session.php @@ -51,7 +51,7 @@ public function __construct( $this->id = $id; - $sessionPath = $this->getAbsolutePath( + $sessionPath = $this->normaliseSavePath( $config["save_path"] ?? self::DEFAULT_SESSION_PATH ); $sessionName = $config["name"] ?? self::DEFAULT_SESSION_NAME; @@ -81,7 +81,7 @@ public function kill():void { public function getStore( string $namespace, bool $createIfNotExists = false - ):?SessionStore { + ):?SessionStoreInterface { return $this->store->getStore( $namespace, $createIfNotExists @@ -113,7 +113,11 @@ public function getId():string { return session_id() ?: ""; } - protected function getAbsolutePath(string $path):string { + protected function normaliseSavePath(string $path):string { + if($this->isDsn($path)) { + return $path; + } + $path = str_replace( ["/", "\\"], DIRECTORY_SEPARATOR, @@ -130,6 +134,10 @@ protected function getAbsolutePath(string $path):string { return $path; } + protected function isDsn(string $path):bool { + return (bool)preg_match('/^[a-z][a-z0-9+.-]*:\/\//i', $path); + } + /** @SuppressWarnings("PHPMD.Superglobals") */ protected function createNewId():string { if(($this->config["use_trans_sid"] ?? null) @@ -166,12 +174,15 @@ public function write():bool { * @param string $sessionPath * @param string $sessionName * @param array $config + * @SuppressWarnings("PHPMD.UnusedFormalParameter") * @return void */ + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed private function attemptStart( string $sessionPath, string $sessionName, array $config, + ?string $unusedContext = null, ):void { $sessionOptions = $this->getSessionOptions( $sessionPath, diff --git a/src/SessionStore.php b/src/SessionStore.php index d8e4c4e..99ee155 100644 --- a/src/SessionStore.php +++ b/src/SessionStore.php @@ -10,9 +10,7 @@ * @extends ArrayIterator * @SuppressWarnings("PHPMD.TooManyPublicMethods") */ -class SessionStore - extends ArrayIterator - implements SessionContainer, TypeSafeGetter, Countable { +class SessionStore extends ArrayIterator implements SessionStoreInterface { use NullableTypeSafeGetter; protected string $name; diff --git a/src/SessionStoreFactory.php b/src/SessionStoreFactory.php index aaf6da0..f4cc032 100644 --- a/src/SessionStoreFactory.php +++ b/src/SessionStoreFactory.php @@ -5,7 +5,7 @@ class SessionStoreFactory { public function create( string $namespace, Session $session, - ):SessionStore { + ):SessionStoreInterface { $namespaceParts = explode(".", $namespace); $store = new SessionStore( array_shift($namespaceParts), diff --git a/src/SessionStoreInterface.php b/src/SessionStoreInterface.php new file mode 100644 index 0000000..c58251c --- /dev/null +++ b/src/SessionStoreInterface.php @@ -0,0 +1,22 @@ +expects(self::once()) ->method("get") ->with("queue.test") @@ -28,7 +28,7 @@ public function testConsume_none():void { $queue->expects(self::once()) ->method("dequeue") ->willThrowException(new RuntimeException("Can't shift from an empty datastructure")); - $sessionStore = self::createMock(SessionStore::class); + $sessionStore = self::createMock(SessionStoreInterface::class); $sessionStore->expects(self::once()) ->method("get") ->with("queue.test") @@ -47,7 +47,7 @@ public function testConsume():void { ->method("dequeue") ->willReturnOnConsecutiveCalls($fm1, $fm2); - $sessionStore = self::createMock(SessionStore::class); + $sessionStore = self::createMock(SessionStoreInterface::class); $sessionStore->expects(self::exactly(3)) ->method("get") ->with("queue.test") diff --git a/test/phpunit/RedisHandlerTest.php b/test/phpunit/RedisHandlerTest.php new file mode 100644 index 0000000..b6d3dbd --- /dev/null +++ b/test/phpunit/RedisHandlerTest.php @@ -0,0 +1,182 @@ +client; + } + }; + + $sut->open( + "redis://default:secret@example.internal:25061/2?prefix=prod:session:&ttl=1800&timeout=1.5&read_timeout=2.5&persistent=1&persistent_id=pool-a", + "GT", + ); + + self::assertSame("example.internal", $client->connectParameters["host"]); + self::assertSame(25061, $client->connectParameters["port"]); + self::assertSame(1.5, $client->connectParameters["timeout"]); + self::assertSame(2.5, $client->connectParameters["readTimeout"]); + self::assertSame("pool-a", $client->connectParameters["persistentId"]); + self::assertSame([["default", "secret"]], $client->authCalls); + self::assertSame([2], $client->selectCalls); + + $sut->write("abc123", "payload"); + self::assertSame("payload", $sut->read("abc123")); + self::assertSame( + [ + "key" => "prod:session:abc123", + "ttl" => 1800, + "value" => "payload", + ], + $client->setExCalls[0], + ); + } + + public function testOpenParsesTlsDsn():void { + $client = new TestRedisClient(); + $sut = new class($client) extends RedisHandler { + public function __construct(private readonly TestRedisClient $client) {} + + protected function createClient():Redis { + /** @phpstan-ignore-next-line */ + return $this->client; + } + }; + + $sut->open( + "rediss://:secret@example.internal?verify_peer=0&verify_peer_name=0", + "GT", + ); + $sut->write("abc123", "payload"); + + self::assertSame("tls://example.internal", $client->connectParameters["host"]); + self::assertSame( + [ + "stream" => [ + "verify_peer" => false, + "verify_peer_name" => false, + ], + ], + $client->connectParameters["context"], + ); + self::assertSame(["secret"], $client->authCalls); + self::assertSame("payload", $client->data["GT:abc123"]); + } + + public function testDestroyAndClose():void { + $client = new TestRedisClient(); + $sut = new class($client) extends RedisHandler { + public function __construct(private readonly TestRedisClient $client) {} + + protected function createClient():Redis { + /** @phpstan-ignore-next-line */ + return $this->client; + } + }; + $sut->open("redis://cache.internal", "GT"); + $sut->write("abc123", "payload"); + + self::assertTrue($sut->destroy("abc123")); + self::assertSame("", $sut->read("abc123")); + self::assertTrue($sut->close()); + self::assertTrue($client->closed); + } +} + +class TestRedisClient { + /** @var array */ + public array $connectParameters = []; + /** @var array */ + public array $setExCalls = []; + /** @var array */ + public array $setCalls = []; + /** @var array */ + public array $authCalls = []; + /** @var array */ + public array $selectCalls = []; + /** @var array */ + public array $data = []; + public int $deleted = 0; + public bool $closed = false; + + /** + * @param array{auth?:array{0:string|false|null,1?:string},stream?:array}|null $context + */ + public function connect( + string $host, + int $port, + float $timeout = 0, + ?string $persistentId = null, + int $retryInterval = 0, + float $readTimeout = 0, + ?array $context = null, + ):bool { + $this->connectParameters = [ + "host" => $host, + "port" => $port, + "timeout" => $timeout, + "persistentId" => $persistentId, + "retryInterval" => $retryInterval, + "readTimeout" => $readTimeout, + "context" => $context, + ]; + return true; + } + + /** + * @param array{string,string}|string $credentials + */ + public function auth(array|string $credentials):bool { + $this->authCalls []= $credentials; + return true; + } + + public function select(int $database):bool { + $this->selectCalls []= $database; + return true; + } + + public function get(string $key):string|false { + return $this->data[$key] ?? false; + } + + public function set(string $key, string $value):bool { + $this->setCalls []= $key; + $this->data[$key] = $value; + return true; + } + + public function setEx(string $key, int $ttl, string $value):bool { + $this->setExCalls []= [ + "key" => $key, + "ttl" => $ttl, + "value" => $value, + ]; + $this->data[$key] = $value; + return true; + } + + public function del(string $key):int { + unset($this->data[$key]); + return ++$this->deleted; + } + + public function close():bool { + $this->closed = true; + return true; + } +} + +if(!class_exists(Redis::class)) { + class_alias(TestRedisClient::class, Redis::class); +} diff --git a/test/phpunit/SessionStoreTest.php b/test/phpunit/SessionStoreTest.php index 2124cb3..8202d0f 100644 --- a/test/phpunit/SessionStoreTest.php +++ b/test/phpunit/SessionStoreTest.php @@ -5,6 +5,7 @@ use Gt\Session\Handler; use Gt\Session\Session; use Gt\Session\SessionStore; +use Gt\Session\SessionStoreInterface; use Gt\Session\Test\Helper\FunctionMocker; use Gt\Session\Test\Helper\DataProvider\KeyValuePairProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -172,4 +173,10 @@ public function testSessionStoreIsIterable():void { self::assertSame($rawData[$key], $value); } } + + public function testSessionStoreImplementsInterface():void { + $session = $this->createMock(Session::class); + $sut = new SessionStore("test", $session); + self::assertInstanceOf(SessionStoreInterface::class, $sut); + } } diff --git a/test/phpunit/SessionTest.php b/test/phpunit/SessionTest.php index 9d061e5..38d1c52 100644 --- a/test/phpunit/SessionTest.php +++ b/test/phpunit/SessionTest.php @@ -37,6 +37,18 @@ public function testSessionStarts():void { ); } + public function testSessionStartPreservesDsnSavePath():void { + $handler = self::createMock(Handler::class); + $savePath = "valkey://cache.internal:6379/0?ttl=1440"; + + new Session($handler, [ + "save_path" => $savePath, + ]); + + $sessionStartParameter = FunctionMocker::$mockCalls["session_start"][0][0]; + self::assertSame($savePath, $sessionStartParameter["save_path"]); + } + public function testWriteSessionDataCalled() { $handler = self::createMock(Handler::class); $handler->expects($this->exactly(2))