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