hjtdev-appkit 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appkit/__init__.py +39 -0
- appkit/apps.py +33 -0
- appkit/cache.py +222 -0
- appkit/checks.py +513 -0
- appkit/conf.py +71 -0
- appkit/crypto.py +102 -0
- appkit/dates.py +183 -0
- appkit/exceptions.py +158 -0
- appkit/files.py +302 -0
- appkit/locale/fa/LC_MESSAGES/django.mo +0 -0
- appkit/locale/fa/LC_MESSAGES/django.po +29 -0
- appkit/media.py +102 -0
- appkit/mixins.py +68 -0
- appkit/money.py +69 -0
- appkit/net.py +115 -0
- appkit/pagination.py +30 -0
- appkit/permissions.py +48 -0
- appkit/py.typed +0 -0
- appkit/request_id.py +96 -0
- appkit/testing.py +259 -0
- appkit/text.py +62 -0
- appkit/throttling.py +41 -0
- appkit/validation.py +195 -0
- hjtdev_appkit-2.0.0.dist-info/METADATA +17 -0
- hjtdev_appkit-2.0.0.dist-info/RECORD +28 -0
- hjtdev_appkit-2.0.0.dist-info/WHEEL +5 -0
- hjtdev_appkit-2.0.0.dist-info/licenses/LICENSE +21 -0
- hjtdev_appkit-2.0.0.dist-info/top_level.txt +1 -0
appkit/validation.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Query-param validation, HTML sanitisation, and an ORM lookup allowlist for user-driven
|
|
2
|
+
filtering.
|
|
3
|
+
|
|
4
|
+
No custom validation framework — DRF serializers already do the work; this module adds a thin
|
|
5
|
+
helper for validating ``request.query_params`` through a serializer, not a new declaration
|
|
6
|
+
syntax. No generic XSS/SQL-injection scanner either: the ORM already prevents SQL injection, and
|
|
7
|
+
string-scanning for ``<script>`` is a blocklist that provides false confidence, not real
|
|
8
|
+
protection (docs/CONTRACT.md §J).
|
|
9
|
+
|
|
10
|
+
Public surface (docs/CONTRACT.md §2.8):
|
|
11
|
+
|
|
12
|
+
def validate_query_params(serializer_class: type[S], params: QueryDict) -> S: ...
|
|
13
|
+
# raises rest_framework.exceptions.ValidationError
|
|
14
|
+
|
|
15
|
+
def sanitize_html(value: str, *, allowed_tags: Iterable[str] | None = None) -> str: ...
|
|
16
|
+
# default tag set: p, br, strong, em, a, ul, ol, li
|
|
17
|
+
|
|
18
|
+
def strip_html(value: str) -> str: ...
|
|
19
|
+
|
|
20
|
+
ALLOWED_LOOKUPS: Final[frozenset[str]]
|
|
21
|
+
# exact, iexact, contains, icontains, startswith, endswith, gt, gte, lt, lte, in,
|
|
22
|
+
# isnull, range. regex and iregex are excluded on purpose.
|
|
23
|
+
#
|
|
24
|
+
# Reading flag: docs/CONTRACT.md §2.8 writes the first member as "eq/exact" — only
|
|
25
|
+
# "exact" is a real Django ORM lookup (there is no "eq"), so only "exact" goes in the
|
|
26
|
+
# set. Admitting a literal "eq" would let a caller build filter(x__eq=...) and get a
|
|
27
|
+
# FieldError from a function whose entire job is to prevent exactly that.
|
|
28
|
+
|
|
29
|
+
def validate_lookup(lookup: str) -> bool: ...
|
|
30
|
+
|
|
31
|
+
def safe_filter_kwargs(
|
|
32
|
+
params: QueryDict, allowed_fields: Iterable[str], *, allow_relations: bool = False
|
|
33
|
+
) -> dict[str, Any]: ...
|
|
34
|
+
# allow_relations exists specifically to prevent filter-based data exfiltration across
|
|
35
|
+
# relations when left at its default False.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
41
|
+
|
|
42
|
+
import nh3
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Iterable
|
|
46
|
+
|
|
47
|
+
from django.http import QueryDict
|
|
48
|
+
from rest_framework.serializers import Serializer
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"ALLOWED_LOOKUPS",
|
|
52
|
+
"safe_filter_kwargs",
|
|
53
|
+
"sanitize_html",
|
|
54
|
+
"strip_html",
|
|
55
|
+
"validate_lookup",
|
|
56
|
+
"validate_query_params",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
#: nh3's own default HTML tag allowlist is much larger than this — this is appkit's
|
|
60
|
+
#: deliberately minimal default for "safe rich text" (a comment body, a bio field), not nh3's.
|
|
61
|
+
_DEFAULT_ALLOWED_TAGS: Final[frozenset[str]] = frozenset(
|
|
62
|
+
{"p", "br", "strong", "em", "a", "ul", "ol", "li"}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
#: The ORM lookup allowlist. `regex`/`iregex` are excluded on purpose — a user-controlled regex
|
|
66
|
+
#: against Postgres is a ReDoS vector, and this allowlist's whole job is to be the thing an app
|
|
67
|
+
#: checks before building a `filter(**kwargs)` from user input.
|
|
68
|
+
ALLOWED_LOOKUPS: Final[frozenset[str]] = frozenset(
|
|
69
|
+
{
|
|
70
|
+
"exact",
|
|
71
|
+
"iexact",
|
|
72
|
+
"contains",
|
|
73
|
+
"icontains",
|
|
74
|
+
"startswith",
|
|
75
|
+
"endswith",
|
|
76
|
+
"gt",
|
|
77
|
+
"gte",
|
|
78
|
+
"lt",
|
|
79
|
+
"lte",
|
|
80
|
+
"in",
|
|
81
|
+
"isnull",
|
|
82
|
+
"range",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_query_params[S: Serializer[Any]](serializer_class: type[S], params: QueryDict) -> S:
|
|
88
|
+
"""Runs `params` through `serializer_class` for read-side validation, returning the
|
|
89
|
+
validated serializer instance.
|
|
90
|
+
|
|
91
|
+
Raises `rest_framework.exceptions.ValidationError` on invalid input — deliberately DRF's
|
|
92
|
+
own exception, so it flows straight into `standard_exception_handler` without a
|
|
93
|
+
translation layer. This is a thin helper pointing DRF serializers at
|
|
94
|
+
`request.query_params` instead of `request.data`, not a parallel validation framework.
|
|
95
|
+
"""
|
|
96
|
+
serializer = serializer_class(data=params)
|
|
97
|
+
serializer.is_valid(raise_exception=True)
|
|
98
|
+
return serializer
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sanitize_html(value: str, *, allowed_tags: Iterable[str] | None = None) -> str:
|
|
102
|
+
"""`nh3`-based allowlist HTML sanitisation. `allowed_tags=None` uses the documented minimal
|
|
103
|
+
default tag set (`p`, `br`, `strong`, `em`, `a`, `ul`, `ol`, `li`).
|
|
104
|
+
|
|
105
|
+
Never raises; malformed HTML is repaired, not rejected, matching `nh3`'s own behaviour.
|
|
106
|
+
`<script>` tags and `on*=` event-handler attributes are stripped even when nested inside an
|
|
107
|
+
otherwise-allowed tag (`<a onmouseover="...">`) — `nh3` strips any tag not in `tags` (down
|
|
108
|
+
to its inert text content, never its markup) and any attribute not in its own default
|
|
109
|
+
attribute allowlist (which never includes an event handler), at every nesting depth, not
|
|
110
|
+
only the top level.
|
|
111
|
+
"""
|
|
112
|
+
tags = frozenset(allowed_tags) if allowed_tags is not None else _DEFAULT_ALLOWED_TAGS
|
|
113
|
+
return nh3.clean(value, tags=set(tags))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def strip_html(value: str) -> str:
|
|
117
|
+
"""Removes all tags, returning plain text. For fields that must never contain markup at
|
|
118
|
+
all (a display name), not fields that may contain safe rich text.
|
|
119
|
+
"""
|
|
120
|
+
return nh3.clean(value, tags=set())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_lookup(lookup: str) -> bool:
|
|
124
|
+
"""Pure membership check against `ALLOWED_LOOKUPS`. Never raises."""
|
|
125
|
+
return lookup in ALLOWED_LOOKUPS
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _split_filter_key(key: str, *, allow_relations: bool) -> tuple[str, str] | None:
|
|
129
|
+
"""Parses `key` into `(field_path, lookup)`, or `None` if the shape isn't allowed.
|
|
130
|
+
|
|
131
|
+
Counts `__`-delimited segments rather than checking a prefix: `allowed_fields=["created_at"]`
|
|
132
|
+
must accept `?created_at__gte=x` (2 segments, second is a valid lookup) and reject
|
|
133
|
+
`?created_at__related__gte=x` (3 segments) even though the first segment matches.
|
|
134
|
+
"""
|
|
135
|
+
segments = key.split("__")
|
|
136
|
+
|
|
137
|
+
if not allow_relations:
|
|
138
|
+
if len(segments) == 1:
|
|
139
|
+
return segments[0], "exact"
|
|
140
|
+
if len(segments) == 2 and segments[1] in ALLOWED_LOOKUPS:
|
|
141
|
+
return segments[0], segments[1]
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# allow_relations=True: any number of `field__field__...` segments is a candidate relation
|
|
145
|
+
# path; only the *trailing* segment is ever treated as a lookup, and only if it's a
|
|
146
|
+
# recognised one — the caller's `allowed_fields` is what actually decides which paths exist.
|
|
147
|
+
if len(segments) >= 2 and segments[-1] in ALLOWED_LOOKUPS:
|
|
148
|
+
return "__".join(segments[:-1]), segments[-1]
|
|
149
|
+
return "__".join(segments), "exact"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _coerce_filter_value(lookup: str, raw: str) -> Any:
|
|
153
|
+
"""Type-coerces a raw query-param string for the given lookup.
|
|
154
|
+
|
|
155
|
+
Without the `isnull` coercion, `?x__isnull=false` would filter as `True` (any non-empty
|
|
156
|
+
string is truthy) — a silent wrong-results bug, not a style choice.
|
|
157
|
+
"""
|
|
158
|
+
if lookup in ("in", "range"):
|
|
159
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
160
|
+
if lookup == "isnull":
|
|
161
|
+
return raw.strip().lower() in ("1", "true", "yes")
|
|
162
|
+
return raw
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def safe_filter_kwargs(
|
|
166
|
+
params: QueryDict, allowed_fields: Iterable[str], *, allow_relations: bool = False
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Builds a `.filter()`-safe kwargs dict from raw query params.
|
|
169
|
+
|
|
170
|
+
Only `allowed_fields` may appear, only `ALLOWED_LOOKUPS` suffixes are accepted, and unknown
|
|
171
|
+
params are **dropped, not errored** — a client typo degrades to "no filter applied", not a
|
|
172
|
+
500.
|
|
173
|
+
|
|
174
|
+
`allow_relations=False` (the default) is the load-bearing default and the whole point of
|
|
175
|
+
this function's existence: with it, `field__related__field` double-underscore traversal is
|
|
176
|
+
rejected outright — `?user__email__icontains=` cannot be used to exfiltrate another table's
|
|
177
|
+
data through a filter an app author only meant to expose one field of. Passing
|
|
178
|
+
`allow_relations=True` opts a specific relation path in only when that exact dotted path
|
|
179
|
+
(e.g. `"user__email"`) is itself listed in `allowed_fields`.
|
|
180
|
+
"""
|
|
181
|
+
allowed = frozenset(allowed_fields)
|
|
182
|
+
result: dict[str, Any] = {}
|
|
183
|
+
for key in params:
|
|
184
|
+
parsed = _split_filter_key(key, allow_relations=allow_relations)
|
|
185
|
+
if parsed is None:
|
|
186
|
+
continue
|
|
187
|
+
field_path, lookup = parsed
|
|
188
|
+
if field_path not in allowed:
|
|
189
|
+
continue
|
|
190
|
+
raw = params.get(key)
|
|
191
|
+
if raw is None:
|
|
192
|
+
continue
|
|
193
|
+
kwarg_key = field_path if lookup == "exact" else f"{field_path}__{lookup}"
|
|
194
|
+
result[kwarg_key] = _coerce_filter_value(lookup, raw)
|
|
195
|
+
return result
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hjtdev-appkit
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Shared Django + DRF foundation every app package and host in this ecosystem depends on — cache, mixins, error envelope, request-ID plumbing, and the HttpClient/provider contract. Not an installable feature; the thing every other app is built on.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: django<7.0,>=5.2
|
|
9
|
+
Requires-Dist: djangorestframework<4.0,>=3.15
|
|
10
|
+
Requires-Dist: nh3<1.0,>=0.2
|
|
11
|
+
Requires-Dist: puremagic<3,>=2
|
|
12
|
+
Requires-Dist: jdatetime<7,>=5
|
|
13
|
+
Provides-Extra: crypto
|
|
14
|
+
Requires-Dist: cryptography<51,>=42; extra == "crypto"
|
|
15
|
+
Provides-Extra: images
|
|
16
|
+
Requires-Dist: pillow<13,>=11.3; extra == "images"
|
|
17
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
appkit/__init__.py,sha256=Zpwi2ONa8CmyX-ZLCY7NF0fMwVCNaVilYEBpMNqQ1l4,2673
|
|
2
|
+
appkit/apps.py,sha256=Y1Uu1sMIYFB82MFEosIv5NcJ0kO7rZyZhjT0qIYIgag,1215
|
|
3
|
+
appkit/cache.py,sha256=u3B42OPqX4aTfAA0Bf4sW_6T37yyL9MwArJirjx0_wM,10504
|
|
4
|
+
appkit/checks.py,sha256=PZcanJi_zNV-mNPO8EPlIWLp0cRp2g5acTw6_WdG8Zo,24385
|
|
5
|
+
appkit/conf.py,sha256=RNEtJudQJEYdLxW0aIORSOUHGwKOfp1qFH59Du_AFeE,3245
|
|
6
|
+
appkit/crypto.py,sha256=NhhHC3-DWGdH_UhiuIfVoL8d5SIM0o6zv4PxiDU7MqQ,4217
|
|
7
|
+
appkit/dates.py,sha256=cxQRj2KYMCGP9BpJSBDnOc8-wYqr_Vx1-nIeqroK_h4,7826
|
|
8
|
+
appkit/exceptions.py,sha256=g2w9eJ_sV4K__ykcfhov4zmcV3ib3OS4MtKY3XkUniw,6963
|
|
9
|
+
appkit/files.py,sha256=ibOq68Zx9dg5UB0LIZ2WEIzaWl2xphrBOSRJOmxNLyc,11512
|
|
10
|
+
appkit/media.py,sha256=hPFXGtHfRTjeFB5JA2GeuhGcoXyBIq8QTIpZL7IEh10,3945
|
|
11
|
+
appkit/mixins.py,sha256=gjV73yP6PV9vrcfz75wgnhTqSsYx7HFvzO2vwxRBXI4,3022
|
|
12
|
+
appkit/money.py,sha256=3ygp5w6gKme0L-gNsPG_RlUft1JokaQfSw4tFqFgR7o,3143
|
|
13
|
+
appkit/net.py,sha256=MlVugRDh8OOMry5ymGqYnhUyICIZpzE3pcHUfukdNRQ,4468
|
|
14
|
+
appkit/pagination.py,sha256=7XKT6qiP8uVYFaWACOOpBbSQIJTQco20yLmEvoFkyso,1022
|
|
15
|
+
appkit/permissions.py,sha256=IsgAM77cBCt2hZiYbeeSxzSbSkSLe_J8bPAzJeZldIw,1753
|
|
16
|
+
appkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
appkit/request_id.py,sha256=wKaPbWH_sS6g7C9pFjA9gMtod1nhn41ZLjAP6UX8dGs,4939
|
|
18
|
+
appkit/testing.py,sha256=BzLuAXDgKrUOR2Uw3Gra3TxwSWE3B7-rrK9xx1wmFYI,11403
|
|
19
|
+
appkit/text.py,sha256=ktnt00ZORjKsbXQSrrLRB91yBOhi-vgGDfeNWG4eA2I,2681
|
|
20
|
+
appkit/throttling.py,sha256=nvcwYqhjwvxtr5lUQq9xIzAWtenyLMIj7g9iydEFlBo,1949
|
|
21
|
+
appkit/validation.py,sha256=p4VIVGtXEQ8tJYl4kYp6aNTRYJHvH1LyBL-owGiYjh8,8202
|
|
22
|
+
appkit/locale/fa/LC_MESSAGES/django.mo,sha256=SzaSQMeft3nTCS0F6PqnZ0r7e84MuU8E6g2RyfCnRgA,634
|
|
23
|
+
appkit/locale/fa/LC_MESSAGES/django.po,sha256=g8ghpaWUA2yT2BFqxaeswxYgp25qXfccd5S3DaA2QP0,850
|
|
24
|
+
hjtdev_appkit-2.0.0.dist-info/licenses/LICENSE,sha256=Fo2xRX9Qv4cl-jHlwoUOMUyLa36ibcxL6uf3viFRGHo,1083
|
|
25
|
+
hjtdev_appkit-2.0.0.dist-info/METADATA,sha256=QMb3xUdF8wJ_aHFj1nor9vVgiRfMEGlRzM4GBUUSsww,727
|
|
26
|
+
hjtdev_appkit-2.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
27
|
+
hjtdev_appkit-2.0.0.dist-info/top_level.txt,sha256=DBzILdCgXpBuKO5efVZjyoSdJ-t6fKsiz4-rKwrhpRg,7
|
|
28
|
+
hjtdev_appkit-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammad Hojjat Nikoobakht
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
appkit
|