1from collections.abc import Generator, Iterable
2from email.utils import formatdate
3from typing import Any
4from urllib.parse import quote, unquote
5from urllib.parse import urlencode as original_urlencode
6
7from plain.utils.datastructures import MultiValueDict
8
9RFC3986_SUBDELIMS = "!$&'()*+,;="
10
11
12def urlencode(
13 query: MultiValueDict | dict[str, Any] | Iterable[tuple[str, Any]],
14 doseq: bool = False,
15) -> str:
16 """
17 A version of Python's urllib.parse.urlencode() function that can operate on
18 MultiValueDict and non-string values.
19 """
20 if isinstance(query, MultiValueDict):
21 query = query.lists()
22 elif hasattr(query, "items"):
23 query = query.items() # ty: ignore[call-non-callable]
24 query_params = []
25 for key, value in query:
26 if value is None:
27 raise TypeError(
28 f"Cannot encode None for key '{key}' in a query string. Did you "
29 "mean to pass an empty string or omit the value?"
30 )
31 elif not doseq or isinstance(value, str | bytes):
32 query_val = value
33 else:
34 try:
35 itr = iter(value)
36 except TypeError:
37 query_val = value
38 else:
39 # Consume generators and iterators, when doseq=True, to
40 # work around https://bugs.python.org/issue31706.
41 query_val = []
42 for item in itr:
43 if item is None:
44 raise TypeError(
45 f"Cannot encode None for key '{key}' in a query "
46 "string. Did you mean to pass an empty string or "
47 "omit the value?"
48 )
49 elif not isinstance(item, bytes):
50 item = str(item)
51 query_val.append(item)
52 query_params.append((key, query_val))
53 return original_urlencode(query_params, doseq)
54
55
56def http_date(epoch_seconds: float | None = None) -> str:
57 """
58 Format the time to match the RFC 5322 date format as specified by RFC 9110
59 Section 5.6.7.
60
61 `epoch_seconds` is a floating point number expressed in seconds since the
62 epoch, in UTC - such as that outputted by time.time(). If set to None, it
63 defaults to the current time.
64
65 Output a string in the format 'Wdy, DD Mon YYYY HH:MM:SS GMT'.
66 """
67 return formatdate(epoch_seconds, usegmt=True)
68
69
70# Base 36 functions: useful for generating compact URLs
71
72
73def base36_to_int(s: str) -> int:
74 """
75 Convert a base 36 string to an int. Raise ValueError if the input won't fit
76 into an int.
77 """
78 # To prevent overconsumption of server resources, reject any
79 # base36 string that is longer than 13 base36 digits (13 digits
80 # is sufficient to base36-encode any 64-bit integer)
81 if len(s) > 13:
82 raise ValueError("Base36 input too large")
83 return int(s, 36)
84
85
86def int_to_base36(i: int) -> str:
87 """Convert an integer to a base36 string."""
88 char_set = "0123456789abcdefghijklmnopqrstuvwxyz"
89 if i < 0:
90 raise ValueError("Negative base36 conversion input.")
91 if i < 36:
92 return char_set[i]
93 b36 = ""
94 while i != 0:
95 i, n = divmod(i, 36)
96 b36 = char_set[n] + b36
97 return b36
98
99
100def escape_leading_slashes(url: str) -> str:
101 """
102 If redirecting to an absolute path (two leading slashes), a slash must be
103 escaped to prevent browsers from handling the path as schemaless and
104 redirecting to another host.
105 """
106 if url.startswith("//"):
107 url = "/%2F{}".format(url.removeprefix("//"))
108 return url
109
110
111def _parseparam(s: str) -> Generator[str]:
112 while s[:1] == ";":
113 s = s[1:]
114 end = s.find(";")
115 while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2:
116 end = s.find(";", end + 1)
117 if end < 0:
118 end = len(s)
119 f = s[:end]
120 yield f.strip()
121 s = s[end:]
122
123
124def parse_header_parameters(line: str) -> tuple[str, dict[str, str]]:
125 """
126 Parse a Content-type like header.
127 Return the main content-type and a dictionary of options.
128 """
129 parts = _parseparam(";" + line)
130 key = parts.__next__().lower()
131 pdict = {}
132 for p in parts:
133 i = p.find("=")
134 if i >= 0:
135 has_encoding = False
136 name = p[:i].strip().lower()
137 if name.endswith("*"):
138 # Lang/encoding embedded in the value (like "filename*=UTF-8''file.ext")
139 # https://tools.ietf.org/html/rfc2231#section-4
140 name = name[:-1]
141 if p.count("'") == 2:
142 has_encoding = True
143 value = p[i + 1 :].strip()
144 if len(value) >= 2 and value[0] == value[-1] == '"':
145 value = value[1:-1]
146 value = value.replace("\\\\", "\\").replace('\\"', '"')
147 if has_encoding:
148 encoding, _, value = value.split("'")
149 value = unquote(value, encoding=encoding)
150 pdict[name] = value
151 return key, pdict
152
153
154def content_disposition_header(as_attachment: bool, filename: str) -> str | None:
155 """
156 Construct a Content-Disposition HTTP header value from the given filename
157 as specified by RFC 6266.
158 """
159 if filename:
160 disposition = "attachment" if as_attachment else "inline"
161 try:
162 filename.encode("ascii")
163 file_expr = 'filename="{}"'.format(
164 filename.replace("\\", "\\\\").replace('"', r"\"")
165 )
166 except UnicodeEncodeError:
167 file_expr = f"filename*=utf-8''{quote(filename)}"
168 return f"{disposition}; {file_expr}"
169 elif as_attachment:
170 return "attachment"
171 else:
172 return None