diff --git a/requirements.txt b/requirements.txt index 88afae75e5..bee66deb3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,6 +29,7 @@ dnspython[idna]==2.7.0 # utils yarl==1.16.0 +propcache==0.2.0 colorlog==6.8.2 APScheduler==3.10.4 python-dotenv==1.0.1 diff --git a/src/web/utils.py b/src/web/utils.py index e54dc4a85e..dfbf8a1842 100644 --- a/src/web/utils.py +++ b/src/web/utils.py @@ -23,11 +23,12 @@ import email.utils import feedparser from contextlib import suppress -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime, timezone, timedelta from ipaddress import ip_address, ip_network from urllib.parse import urlparse from multidict import CIMultiDictProxy +from propcache import cached_property from .. import env, log from ..i18n import i18n @@ -136,6 +137,9 @@ def rfc_2822_8601_to_datetime(time_str: Optional[str]) -> Optional[datetime]: @dataclass class WebResponse: + AGE_REMAINING_CLAMP_MIN: ClassVar[int] = 0 + AGE_REMAINING_CLAMP_MAX: ClassVar[int] = 3600 # 1 hour + url: str # redirected url ori_url: str # original url content: Optional[AnyStr] @@ -143,92 +147,63 @@ class WebResponse: status: int reason: Optional[str] - _now: Optional[datetime] = field(default=sentinel, init=False, repr=False, hash=False, compare=False) - _date: Optional[datetime] = field(default=sentinel, init=False, repr=False, hash=False, compare=False) - _last_modified: Optional[datetime] = field(default=sentinel, init=False, repr=False, hash=False, compare=False) - _max_age: Optional[int] = field(default=sentinel, init=False, repr=False, hash=False, compare=False) - _age: Optional[int] = field(default=sentinel, init=False, repr=False, hash=False, compare=False) - _age_remaining: Optional[int] = field(default=sentinel, init=False, repr=False, hash=False, compare=False) - _expires: Optional[datetime] = field(default=sentinel, init=False, repr=False, hash=False, compare=False) - - _age_remaining_clamp: ClassVar[range] = range(0, 3600 + 1) # 1 hour - - @property + @cached_property def etag(self) -> Optional[str]: - return self.headers.get('ETag') + return self.headers.get('ETag') or None # Prohibit empty string - @property + @cached_property def now(self) -> datetime: - if self._now is sentinel: - self._now = datetime.now(timezone.utc) - return self._now + return datetime.now(timezone.utc) - @now.setter - def now(self, value: datetime): - self._now = value - - @property + @cached_property def date(self) -> datetime: - if self._date is sentinel: - self._date = rfc_2822_8601_to_datetime(self.headers.get('Date')) or self.now - return self._date + return rfc_2822_8601_to_datetime(self.headers.get('Date')) or self.now - @property + @cached_property def last_modified(self) -> datetime: - if self._last_modified is sentinel: - self._last_modified = rfc_2822_8601_to_datetime(self.headers.get('Last-Modified')) or self.date - return self._last_modified + return rfc_2822_8601_to_datetime(self.headers.get('Last-Modified')) or self.date - @property + @cached_property def max_age(self) -> Optional[int]: - if self._max_age is not sentinel: - return self._max_age cache_control = self.headers.get('Cache-Control', '').lower() if not cache_control: - self._max_age = None + return None elif 'no-cache' in cache_control or 'no-store' in cache_control: - self._max_age = 0 + return 0 elif max_age := cache_control.partition('max-age=')[2].partition(',')[0]: try: - self._max_age = int(max_age) if max_age else None + return int(max_age) if max_age else None except ValueError: - self._max_age = None - else: - self._max_age = None - return self._max_age + return None + return None - @property + @cached_property def age(self) -> Optional[int]: - if self._age is sentinel: - age = self.headers.get('Age') - try: - self._age = int(age) if age else None - except ValueError: - self._age = None - return self._age + age = self.headers.get('Age') + try: + return int(age) if age else None + except ValueError: + return None - @property + @cached_property def age_remaining(self) -> Optional[int]: - if self._age_remaining is sentinel: - if self.max_age is None: - self._age_remaining = None - else: - self._age_remaining = self.max_age - (self.age or 0) - if self._age_remaining < self._age_remaining_clamp.start: - self._age_remaining = self._age_remaining_clamp.start - elif self._age_remaining > self._age_remaining_clamp.stop: - self._age_remaining = self._age_remaining_clamp.stop - return self._age_remaining - - @property + if self.max_age is None: + return None + else: + age_remaining = self.max_age - (self.age or 0) + if age_remaining < (clamp_min := self.AGE_REMAINING_CLAMP_MIN): + return clamp_min + elif age_remaining > (clamp_max := self.AGE_REMAINING_CLAMP_MAX): + return clamp_max + return age_remaining + + @cached_property def expires(self) -> Optional[datetime]: - if self._expires is sentinel: - # max-age overrides Expires: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires - if self.age_remaining is None: - self._expires = rfc_2822_8601_to_datetime(self.headers.get('Expires')) - else: - self._expires = self.date + timedelta(seconds=self.age_remaining) - return self._expires + # max-age overrides Expires: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires + if self.age_remaining is None: + return rfc_2822_8601_to_datetime(self.headers.get('Expires')) + else: + return self.date + timedelta(seconds=self.age_remaining) @dataclass