1from __future__ import annotations
  2
  3import re
  4from datetime import datetime
  5from typing import TYPE_CHECKING
  6
  7from plain import models
  8from plain.models import types
  9
 10if TYPE_CHECKING:
 11    from plain.http import Request
 12
 13__all__ = ["NotFoundLog", "Redirect", "RedirectLog"]
 14
 15
 16@models.register_model
 17class Redirect(models.Model):
 18    from_pattern: str = types.CharField(max_length=255)
 19    to_pattern: str = types.CharField(max_length=255)
 20    http_status: int = types.PositiveSmallIntegerField(
 21        default=301
 22    )  # Default to permanent - could be choices?
 23    created_at: datetime = types.DateTimeField(auto_now_add=True)
 24    updated_at: datetime = types.DateTimeField(auto_now=True)
 25    order: int = types.PositiveSmallIntegerField(default=0)
 26    enabled: bool = types.BooleanField(default=True)
 27    is_regex: bool = types.BooleanField(default=False)
 28
 29    # query params?
 30    # logged in or not? auth not required necessarily...
 31    # headers?
 32
 33    query: models.QuerySet[Redirect] = models.QuerySet()
 34
 35    model_options = models.Options(
 36        ordering=["order", "-created_at"],
 37        indexes=[
 38            models.Index(fields=["order"]),
 39            models.Index(fields=["created_at"]),
 40        ],
 41        constraints=[
 42            models.UniqueConstraint(
 43                fields=["from_pattern"],
 44                name="plainredirects_redirect_unique_from_pattern",
 45            ),
 46        ],
 47    )
 48
 49    def __str__(self) -> str:
 50        return f"{self.from_pattern}"
 51
 52    def matches_request(self, request: Request) -> bool:
 53        """
 54        Decide whether a request matches this Redirect,
 55        automatically checking whether the pattern is path based or full URL based.
 56        """
 57
 58        if self.from_pattern.startswith("http"):
 59            # Full url with query params
 60            url = request.build_absolute_uri()
 61        else:
 62            # Doesn't include query params or host
 63            url = request.path
 64
 65        if self.is_regex:
 66            return bool(re.match(self.from_pattern, url))
 67        else:
 68            return url == self.from_pattern
 69
 70    def get_redirect_url(self, request: Request) -> str:
 71        if not self.is_regex:
 72            return self.to_pattern
 73
 74        # Replace any regex groups in the to_pattern
 75        if self.from_pattern.startswith("http"):
 76            url = request.build_absolute_uri()
 77        else:
 78            url = request.path
 79
 80        return re.sub(self.from_pattern, self.to_pattern, url)
 81
 82
 83@models.register_model
 84class RedirectLog(models.Model):
 85    redirect: Redirect = types.ForeignKeyField(Redirect, on_delete=models.CASCADE)
 86
 87    # The actuals that were used to redirect
 88    from_url: str = types.URLField(max_length=512)
 89    to_url: str = types.URLField(max_length=512)
 90    http_status: int = types.PositiveSmallIntegerField(default=301)
 91
 92    # Request metadata
 93    ip_address: str = types.GenericIPAddressField()
 94    user_agent: str = types.CharField(required=False, max_length=512)
 95    referrer: str = types.CharField(required=False, max_length=512)
 96
 97    created_at: datetime = types.DateTimeField(auto_now_add=True)
 98
 99    query: models.QuerySet[RedirectLog] = models.QuerySet()
100
101    model_options = models.Options(
102        ordering=["-created_at"],
103        indexes=[
104            models.Index(fields=["created_at"]),
105        ],
106    )
107
108    @classmethod
109    def from_redirect(cls, redirect: Redirect, request: Request) -> RedirectLog:
110        from_url = request.build_absolute_uri()
111        to_url = redirect.get_redirect_url(request)
112
113        if not to_url.startswith("http"):
114            to_url = request.build_absolute_uri(to_url)
115
116        if from_url == to_url:
117            raise ValueError("Redirecting to the same URL")
118
119        return cls.query.create(
120            redirect=redirect,
121            from_url=from_url,
122            to_url=to_url,
123            http_status=redirect.http_status,
124            ip_address=request.client_ip,
125            user_agent=request.headers.get("User-Agent", ""),
126            referrer=request.headers.get("Referer", ""),
127        )
128
129
130@models.register_model
131class NotFoundLog(models.Model):
132    url: str = types.URLField(max_length=512)
133
134    # Request metadata
135    ip_address: str = types.GenericIPAddressField()
136    user_agent: str = types.CharField(required=False, max_length=512)
137    referrer: str = types.CharField(required=False, max_length=512)
138
139    created_at: datetime = types.DateTimeField(auto_now_add=True)
140
141    query: models.QuerySet[NotFoundLog] = models.QuerySet()
142
143    model_options = models.Options(
144        ordering=["-created_at"],
145        indexes=[
146            models.Index(fields=["created_at"]),
147        ],
148    )
149
150    @classmethod
151    def from_request(cls, request: Request) -> NotFoundLog:
152        return cls.query.create(
153            url=request.build_absolute_uri(),
154            ip_address=request.client_ip,
155            user_agent=request.headers.get("User-Agent", ""),
156            referrer=request.headers.get("Referer", ""),
157        )