diff --git a/requirements.txt b/requirements.txt index 73c7089c2..042198d36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,8 @@ argon2-cffi bcrypt>=3.2,<3.3 black cleo>=0.8.1,<0.9 -cryptography>=3.3.1,<4.0 -dotty_dict>=1.3.0<1.40 +cryptography>=36,<37 +dotty_dict>=1.3.0,<1.40 exceptionite>=2.0,<3 hashids>=1.3,<1.4 hfilesize>=0.1 @@ -20,7 +20,7 @@ python-dotenv>=0.15,<0.16 responses slackblocks tldextract>=2.2,<2.3 -werkzeug>=2<3 -watchdog>=2<3 +werkzeug>=2,<3 +watchdog>=2,<3 whitenoise>=5.2,<5.3 pyjwt>=2.3,<2.5 diff --git a/src/masonite/cache/drivers/RedisDriver.py b/src/masonite/cache/drivers/RedisDriver.py index b49c18c02..5bc760dec 100644 --- a/src/masonite/cache/drivers/RedisDriver.py +++ b/src/masonite/cache/drivers/RedisDriver.py @@ -1,6 +1,7 @@ import json from typing import Any, TYPE_CHECKING +import pendulum as pdlm if TYPE_CHECKING: from redis import Redis @@ -9,13 +10,17 @@ class RedisDriver: def __init__(self, application): self.application = application self.connection = None - self._internal_cache: "dict|None" = None + self.options = {} + self._internal_cache: dict = None def set_options(self, options: dict) -> "RedisDriver": self.options = options return self def get_connection(self) -> "Redis": + if self.connection: + return self.connection + try: from redis import Redis except ImportError: @@ -23,45 +28,43 @@ def get_connection(self) -> "Redis": "Could not find the 'redis' library. Run 'pip install redis' to fix this." ) - if not self.connection: - self.connection = Redis( - **self.options.get("options", {}), - host=self.options.get("host"), - port=self.options.get("port"), - password=self.options.get("password"), - decode_responses=True, - ) - - # populate the internal cache the first time - # the connection is established - if self._internal_cache is None and self.connection: - self._load_from_store(self.connection) + self.connection = Redis( + **self.options.get("options", {}), + host=self.options.get("host"), + port=self.options.get("port"), + password=self.options.get("password"), + decode_responses=True, + ) return self.connection - def _load_from_store(self, connection: "Redis" = None) -> None: + def _load_from_store(self) -> None: """ copy all the "cache" key value pairs for faster access """ - if not connection: + if self._internal_cache is not None: return - if self._internal_cache is None: - self._internal_cache = {} + self._internal_cache = {} cursor = "0" prefix = self.get_cache_namespace() while cursor != 0: - cursor, keys = connection.scan( + cursor, keys = self.get_connection().scan( cursor=cursor, match=prefix + "*", count=100000 ) if keys: - values = connection.mget(*keys) + values = self.get_connection().mget(*keys) store_data = dict(zip(keys, values)) for key, value in store_data.items(): key = key.replace(prefix, "") value = self.unpack_value(value) - self._internal_cache.setdefault(key, value) + # we dont load the ttl (expiry) + # because there is an O(N) performance hit + self._internal_cache[key] = { + "value": value, + "expires": None, + } def get_cache_namespace(self) -> str: """ @@ -72,37 +75,66 @@ def get_cache_namespace(self) -> str: return f"{namespace}cache:" def add(self, key: str, value: Any = None) -> Any: - if not value: + if value is None: return None self.put(key, value) return value def get(self, key: str, default: Any = None, **options) -> Any: - if default and not self.has(key): - self.put(key, default, **options) - return default - - return self._internal_cache.get(key) + self._load_from_store() + if not self.has(key): + return default or None + + key_expiry = self._internal_cache[key].get("expires", None) + if key_expiry is None: + # the ttl value can also provide info on the + # existence of the key in the store + ttl = self.get_connection().ttl(key) + if ttl == -1: + # key exists but has no set ttl + ttl = self.get_default_timeout() + elif ttl == -2: + # key not found in store + self._internal_cache.pop(key) + return default or None + + key_expiry = self._expires_from_ttl(ttl) + self._internal_cache[key]["expires"] = key_expiry + + if pdlm.now() > key_expiry: + # the key has expired so remove it from the cache + self._internal_cache.pop(key) + return default or None + + # the key has not yet expired + return self._internal_cache.get(key)["value"] def put(self, key: str, value: Any = None, seconds: int = None, **options) -> Any: if not key or value is None: return None - time = self.get_expiration_time(seconds) - store_value = value if isinstance(value, (dict, list, tuple)): store_value = json.dumps(value) + elif isinstance(value, int): + store_value = str(value) + self._load_from_store() + key_ttl = seconds or self.get_default_timeout() self.get_connection().set( - f"{self.get_cache_namespace()}{key}", store_value, ex=time + f"{self.get_cache_namespace()}{key}", store_value, ex=key_ttl ) - - if not self.has(key): - self._internal_cache.update({key: value}) + expires = self._expires_from_ttl(key_ttl) + self._internal_cache.update({ + key: { + "value": value, + "expires": expires, + } + }) def has(self, key: str) -> bool: + self._load_from_store() return key in self._internal_cache def increment(self, key: str, amount: int = 1) -> int: @@ -126,23 +158,28 @@ def remember(self, key: str, callable): return self.get(key) def forget(self, key: str) -> None: + if not self.has(key): + return self.get_connection().delete(f"{self.get_cache_namespace()}{key}") self._internal_cache.pop(key) def flush(self) -> None: - return self.get_connection().flushall() + self.get_connection().flushall() + self._internal_cache = None - def get_expiration_time(self, seconds: int) -> int: - if seconds is None: - seconds = 31557600 * 10 - - return seconds + def get_default_timeout(self) -> int: + # if unset default timeout of cache vars is 1 month + return int(self.options.get("timeout", 60 * 60 * 24 * 30)) def unpack_value(self, value: Any) -> Any: value = str(value) if value.isdigit(): - return str(value) + return int(value) + try: return json.loads(value) except json.decoder.JSONDecodeError: return value + + def _expires_from_ttl(self, ttl: int) -> pdlm.DateTime: + return pdlm.now().add(seconds=ttl) diff --git a/src/masonite/cookies/Cookie.py b/src/masonite/cookies/Cookie.py index 4ba03b611..2743179a1 100644 --- a/src/masonite/cookies/Cookie.py +++ b/src/masonite/cookies/Cookie.py @@ -1,23 +1,19 @@ +from masonite.configuration import config + + class Cookie: - def __init__( - self, - name, - value, - expires=None, - http_only=True, - path="/", - timezone=None, - secure=False, - samesite="Strict", - ): + def __init__(self, name, value, **options): + self.options = options + self.name = name self.value = value - self.http_only = http_only - self.secure = secure - self.expires = expires - self.timezone = timezone - self.samesite = samesite - self.path = path + self.http_only = self.__get_option('http_only', True) + self.secure = self.__get_option('secure', False) + self.expires = self.__get_option('expires', None) + self.timezone = self.__get_option('timezone', None) + self.samesite = self.__get_option('samesite', 'Strict') + self.path = self.__get_option('path', '/') + self.encrypt = self.__get_option('encrypt', True) def render(self): response = f"{self.name}={self.value};" @@ -36,3 +32,16 @@ def render(self): response += f"SameSite={self.samesite};" return response + + def __get_option(self, key: str, default: any): + """ + Get cookie options from config/session.py + if option key found in options then it return that + if not found in options then it will fetch from config + if not found in config then use the default value + """ + if key in self.options: + return self.options[key] + else: + cookie = config('session.drivers.cookie') + return cookie[key] if key in cookie else default diff --git a/src/masonite/drivers/queue/DatabaseDriver.py b/src/masonite/drivers/queue/DatabaseDriver.py index dba74141d..4b38d3fe2 100644 --- a/src/masonite/drivers/queue/DatabaseDriver.py +++ b/src/masonite/drivers/queue/DatabaseDriver.py @@ -89,7 +89,8 @@ def consume(self): getattr(obj, callback)(*args) except AttributeError: - obj(*args) + if callable(obj): + obj(*args) self.success( f"[{job['id']}][{pendulum.now(tz=self.options.get('tz', 'UTC')).to_datetime_string()}] Job Successfully Processed" diff --git a/src/masonite/middleware/route/EncryptCookies.py b/src/masonite/middleware/route/EncryptCookies.py index 6995d31e7..8234e50eb 100644 --- a/src/masonite/middleware/route/EncryptCookies.py +++ b/src/masonite/middleware/route/EncryptCookies.py @@ -4,6 +4,9 @@ class EncryptCookies: def before(self, request, response): for _, cookie in request.cookie_jar.all().items(): + if not cookie.encrypt: + continue + try: cookie.value = request.app.make("sign").unsign(cookie.value) except InvalidToken: @@ -13,6 +16,9 @@ def before(self, request, response): def after(self, request, response): for _, cookie in response.cookie_jar.all().items(): + if not cookie.encrypt: + continue + try: cookie.value = request.app.make("sign").sign(cookie.value) except InvalidToken: diff --git a/src/masonite/validation/Validator.py b/src/masonite/validation/Validator.py index 940bb8e3d..c3de22848 100644 --- a/src/masonite/validation/Validator.py +++ b/src/masonite/validation/Validator.py @@ -759,10 +759,16 @@ def passes(self, attribute, key, dictionary): all_clear = False if self.special != 0: - special_chars = "[^A-Za-z0-9]" + # custom specials are just a string of characters + # and may contain regex meta chars. + # so we search for them differently if self.special_chars: - special_chars = f"[{self.special_chars}]" - if len(re.findall(special_chars, attribute)) < self.special: + special_count = sum(attribute.count(c) for c in self.special_chars) + else: + std_specials = "[^A-Za-z0-9]" + special_count = len(re.findall(std_specials, attribute)) + + if special_count < self.special: self.special_check = False all_clear = False diff --git a/tests/core/middleware/test_encrypt_cookies.py b/tests/core/middleware/test_encrypt_cookies.py index 2f9014e4b..909ac14d0 100644 --- a/tests/core/middleware/test_encrypt_cookies.py +++ b/tests/core/middleware/test_encrypt_cookies.py @@ -15,3 +15,16 @@ def test_encrypts_cookies(self): response.cookie("test", "value") EncryptCookies().after(request, response) self.assertNotEqual(response.cookie("test"), "value") + + def test_encrypt_cookies_opt_out(self): + request = self.make_request( + {"HTTP_COOKIE": f"test_key=test value"} + ) + + response = self.make_response() + EncryptCookies().before(request, None) + self.assertEqual(request.cookie("test_key", encrypt=False), "test value") + + response.cookie("test", "value") + EncryptCookies().after(request, response) + self.assertNotEqual(response.cookie("test_key", encrypt=False), "test value") diff --git a/tests/features/cache/test_redis_cache.py b/tests/features/cache/test_redis_cache.py index 61e69c2ab..696699f6a 100644 --- a/tests/features/cache/test_redis_cache.py +++ b/tests/features/cache/test_redis_cache.py @@ -11,7 +11,7 @@ def setUp(self): self.application.make("cache") self.driver = self.application.make("cache").store("redis") - def test_can_add_file_driver(self): + def test_can_add_redis_driver(self): self.assertEqual(self.driver.add("add_key", "value"), "value") def test_can_get_driver(self): @@ -23,9 +23,13 @@ def test_can_increment(self): self.driver.put("count", "1") self.assertEqual(self.driver.get("count"), "1") self.driver.increment("count") - self.assertEqual(self.driver.get("count"), "2") + self.assertEqual(self.driver.get("count"), 2) + self.driver.increment("count", 3) + self.assertEqual(self.driver.get("count"), 5) self.driver.decrement("count") - self.assertEqual(self.driver.get("count"), "1") + self.assertEqual(self.driver.get("count"), 4) + self.driver.decrement("count", 2) + self.assertEqual(self.driver.get("count"), 2) def test_will_not_get_expired(self): self.driver.put("expire", "1", 1) diff --git a/tests/features/validation/test_validation.py b/tests/features/validation/test_validation.py index c43d5cf6a..7737bab06 100644 --- a/tests/features/validation/test_validation.py +++ b/tests/features/validation/test_validation.py @@ -1677,14 +1677,26 @@ def test_strong(self): # test custom special characters validate = Validator().validate( { - "password": "secret&-", + "password": "$e]cret&-", }, - strong(["password"], length=5, uppercase=0, special=2, special_chars="*&^", numbers=0, lowercase=4), + strong( + ["password"], + length=5, + uppercase=0, + special=4, + special_chars="^$*&()[]", + numbers=0, + lowercase=4, + ), ) self.assertEqual( validate.all(), - {"password": ["The password field must contain at least 2 of these characters: '*&^'"]}, + { + "password": [ + "The password field must contain at least 4 of these characters: '^$*&()[]'" + ] + }, ) validate = Validator().validate(