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/files.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Upload validation via magic-byte sniffing, plus image validation.
|
|
2
|
+
|
|
3
|
+
Public surface (docs/CONTRACT.md §2.9):
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class ImageInfo:
|
|
7
|
+
width: int
|
|
8
|
+
height: int
|
|
9
|
+
format: str
|
|
10
|
+
|
|
11
|
+
def detect_mimetype(data: bytes) -> str: ...
|
|
12
|
+
# puremagic stays internal — no third-party type leaks into this signature.
|
|
13
|
+
|
|
14
|
+
def validate_upload(
|
|
15
|
+
file: UploadedFile, *, allowed_mimetypes: Iterable[str], max_bytes: int = UNSET
|
|
16
|
+
) -> None: ...
|
|
17
|
+
# raises django.core.exceptions.ValidationError; must file.seek(0) in a finally.
|
|
18
|
+
# max_bytes accepts appkit.conf.UNSET, meaning "use APPKIT['MAX_UPLOAD_BYTES']".
|
|
19
|
+
|
|
20
|
+
def validate_image(
|
|
21
|
+
file: UploadedFile,
|
|
22
|
+
*,
|
|
23
|
+
max_bytes: int = UNSET,
|
|
24
|
+
max_dimensions: tuple[int, int] | None = None,
|
|
25
|
+
allow_svg: bool = False,
|
|
26
|
+
) -> ImageInfo: ...
|
|
27
|
+
# Requires the `images` extra (Pillow) for anything beyond header-only dimension
|
|
28
|
+
# reads. SVG is rejected unless allow_svg=True — an XML script-execution vector
|
|
29
|
+
# otherwise.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
|
+
|
|
38
|
+
import puremagic
|
|
39
|
+
from django.core.exceptions import ValidationError
|
|
40
|
+
|
|
41
|
+
from appkit.conf import UNSET, _Unset, get_setting
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from collections.abc import Iterable
|
|
45
|
+
|
|
46
|
+
from django.core.files.uploadedfile import UploadedFile
|
|
47
|
+
|
|
48
|
+
__all__ = ["ImageInfo", "detect_mimetype", "validate_image", "validate_upload"]
|
|
49
|
+
|
|
50
|
+
_INSTALL_HINT_IMAGES = (
|
|
51
|
+
'Install with: uv add "hjtdev-appkit[images]" (or: pip install "hjtdev-appkit[images]")'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Explicit, hardcoded extension<->mimetype agreement table — never mimetypes.guess_extension,
|
|
55
|
+
# whose answer depends on the host OS's /etc/mime.types and disagrees across systems for
|
|
56
|
+
# exactly the formats this module exists to check (docs/CONTRACT.md §2.9).
|
|
57
|
+
_EXTENSIONS_BY_MIMETYPE: dict[str, frozenset[str]] = {
|
|
58
|
+
"image/jpeg": frozenset({".jpg", ".jpeg"}),
|
|
59
|
+
"image/png": frozenset({".png"}),
|
|
60
|
+
"image/gif": frozenset({".gif"}),
|
|
61
|
+
"image/webp": frozenset({".webp"}),
|
|
62
|
+
"image/svg+xml": frozenset({".svg"}),
|
|
63
|
+
"application/pdf": frozenset({".pdf"}),
|
|
64
|
+
"text/plain": frozenset({".txt"}),
|
|
65
|
+
"application/zip": frozenset({".zip"}),
|
|
66
|
+
"application/json": frozenset({".json"}),
|
|
67
|
+
"video/mp4": frozenset({".mp4"}),
|
|
68
|
+
"audio/mpeg": frozenset({".mp3"}),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_IMAGE_MIMETYPES: frozenset[str] = frozenset({"image/jpeg", "image/png", "image/gif", "image/webp"})
|
|
72
|
+
_SVG_MIMETYPE = "image/svg+xml"
|
|
73
|
+
|
|
74
|
+
# SVG is plain XML — puremagic's magic-byte database won't identify it, so it needs an explicit
|
|
75
|
+
# check ahead of the byte-sniffing path.
|
|
76
|
+
_SVG_PROLOG_RE = re.compile(rb"<\?xml\b")
|
|
77
|
+
_SVG_TAG_RE = re.compile(rb"<svg\b")
|
|
78
|
+
_SVG_DIMENSION_RE = re.compile(rb'(width|height)\s*=\s*["\']?\s*([0-9.]+)')
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ImageInfo:
|
|
83
|
+
"""Header-derived image metadata returned by `validate_image`."""
|
|
84
|
+
|
|
85
|
+
width: int
|
|
86
|
+
height: int
|
|
87
|
+
format: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _looks_like_svg(data: bytes) -> bool:
|
|
91
|
+
head = data[:2048].lstrip()
|
|
92
|
+
if _SVG_PROLOG_RE.match(head):
|
|
93
|
+
return bool(_SVG_TAG_RE.search(data[:4096]))
|
|
94
|
+
return bool(_SVG_TAG_RE.match(head))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def detect_mimetype(data: bytes) -> str:
|
|
98
|
+
"""Magic-byte sniffing via `puremagic` — never the client-supplied `Content-Type` header
|
|
99
|
+
and never the filename extension, both attacker-controlled and routinely wrong.
|
|
100
|
+
|
|
101
|
+
Returns `"application/octet-stream"` for anything unrecognised rather than raising, so a
|
|
102
|
+
caller decides what "unknown" means for its own upload policy.
|
|
103
|
+
"""
|
|
104
|
+
if _looks_like_svg(data):
|
|
105
|
+
return _SVG_MIMETYPE
|
|
106
|
+
try:
|
|
107
|
+
mimetype = puremagic.from_string(data, mime=True)
|
|
108
|
+
except puremagic.PureError:
|
|
109
|
+
return "application/octet-stream"
|
|
110
|
+
return mimetype or "application/octet-stream"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _extension_of(filename: str) -> str:
|
|
114
|
+
dot = filename.rfind(".")
|
|
115
|
+
return filename[dot:].lower() if dot != -1 else ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _check_extension_agreement(filename: str, mimetype: str) -> None:
|
|
119
|
+
valid_extensions = _EXTENSIONS_BY_MIMETYPE.get(mimetype)
|
|
120
|
+
if valid_extensions is None:
|
|
121
|
+
return # no table entry for this mimetype — nothing to cross-check against
|
|
122
|
+
if _extension_of(filename) not in valid_extensions:
|
|
123
|
+
raise ValidationError(
|
|
124
|
+
f"File extension of {filename!r} does not match its detected type {mimetype!r}.",
|
|
125
|
+
code="appkit_extension_mismatch",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def validate_upload(
|
|
130
|
+
file: UploadedFile[bytes],
|
|
131
|
+
*,
|
|
132
|
+
allowed_mimetypes: Iterable[str],
|
|
133
|
+
max_bytes: int | _Unset = UNSET,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Sniffs `file`, checking size, detected mimetype, and extension/mimetype agreement.
|
|
136
|
+
|
|
137
|
+
`max_bytes=UNSET` resolves to `APPKIT["MAX_UPLOAD_BYTES"]` — a semantic/business-rule
|
|
138
|
+
limit ("reject a 50 MB avatar"), not a DoS control; Django's own
|
|
139
|
+
`DATA_UPLOAD_MAX_MEMORY_SIZE`/`FILE_UPLOAD_MAX_MEMORY_SIZE` are the actual memory/disk
|
|
140
|
+
boundary, already enforced before this function ever runs.
|
|
141
|
+
|
|
142
|
+
Raises `django.core.exceptions.ValidationError` naming which check failed.
|
|
143
|
+
|
|
144
|
+
**The single most important line in this module:** sniffing consumes the file's read
|
|
145
|
+
position, so `file.seek(0)` runs in a `finally` regardless of outcome — without it,
|
|
146
|
+
whatever saves the file afterward (a serializer's `.save()`) would write a
|
|
147
|
+
truncated-to-empty file.
|
|
148
|
+
"""
|
|
149
|
+
limit = get_setting("MAX_UPLOAD_BYTES") if isinstance(max_bytes, _Unset) else max_bytes
|
|
150
|
+
allowed = frozenset(allowed_mimetypes)
|
|
151
|
+
try:
|
|
152
|
+
file.seek(0)
|
|
153
|
+
size = getattr(file, "size", None)
|
|
154
|
+
if size is not None and size > limit:
|
|
155
|
+
raise ValidationError(
|
|
156
|
+
f"File is too large: {size} bytes exceeds the {limit} byte limit.",
|
|
157
|
+
code="appkit_file_too_large",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
head = file.read(4096)
|
|
161
|
+
mimetype = detect_mimetype(head)
|
|
162
|
+
if mimetype not in allowed:
|
|
163
|
+
raise ValidationError(
|
|
164
|
+
f"File type {mimetype!r} is not an allowed upload type.",
|
|
165
|
+
code="appkit_mimetype_not_allowed",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
_check_extension_agreement(getattr(file, "name", "") or "", mimetype)
|
|
169
|
+
finally:
|
|
170
|
+
file.seek(0)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _pil_image_module() -> Any:
|
|
174
|
+
"""Lazily imports `PIL.Image`, behind the `images` extra.
|
|
175
|
+
|
|
176
|
+
A missing extra must fail with an actionable message, never a bare `ImportError` — this
|
|
177
|
+
path is unit-tested by simulating the import failure.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
from PIL import Image
|
|
181
|
+
except ImportError as exc:
|
|
182
|
+
raise ImportError(
|
|
183
|
+
"appkit.files.validate_image requires the 'Pillow' package for image dimension "
|
|
184
|
+
f"reading. {_INSTALL_HINT_IMAGES}"
|
|
185
|
+
) from exc
|
|
186
|
+
return Image
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _svg_image_info(
|
|
190
|
+
file: UploadedFile[bytes], *, max_dimensions: tuple[int, int] | None
|
|
191
|
+
) -> ImageInfo:
|
|
192
|
+
"""Reads `width`/`height` attributes off the SVG root element via a bounded regex scan —
|
|
193
|
+
never touches Pillow, which doesn't support SVG at all.
|
|
194
|
+
|
|
195
|
+
Neither attribute is guaranteed to be present on a valid SVG (viewBox-only documents are
|
|
196
|
+
legal); both default to `0` when absent or non-numeric, and `max_dimensions` is skipped
|
|
197
|
+
when either dimension is unknown rather than comparing against a meaningless `0`.
|
|
198
|
+
"""
|
|
199
|
+
file.seek(0)
|
|
200
|
+
head = file.read(4096)
|
|
201
|
+
dimensions: dict[str, int] = {}
|
|
202
|
+
for match in _SVG_DIMENSION_RE.finditer(head):
|
|
203
|
+
key = match.group(1).decode()
|
|
204
|
+
try:
|
|
205
|
+
dimensions[key] = int(float(match.group(2)))
|
|
206
|
+
except ValueError:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
width = dimensions.get("width", 0)
|
|
210
|
+
height = dimensions.get("height", 0)
|
|
211
|
+
if max_dimensions is not None and width and height:
|
|
212
|
+
max_width, max_height = max_dimensions
|
|
213
|
+
if width > max_width or height > max_height:
|
|
214
|
+
raise ValidationError(
|
|
215
|
+
f"Image dimensions {width}x{height} exceed the maximum {max_width}x{max_height}.",
|
|
216
|
+
code="appkit_dimensions_too_large",
|
|
217
|
+
)
|
|
218
|
+
return ImageInfo(width=width, height=height, format="svg")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def validate_image(
|
|
222
|
+
file: UploadedFile[bytes],
|
|
223
|
+
*,
|
|
224
|
+
max_bytes: int | _Unset = UNSET,
|
|
225
|
+
max_dimensions: tuple[int, int] | None = None,
|
|
226
|
+
allow_svg: bool = False,
|
|
227
|
+
) -> ImageInfo:
|
|
228
|
+
"""Everything `validate_upload` does, restricted to image mimetypes, plus a
|
|
229
|
+
decompression-bomb-aware dimension check.
|
|
230
|
+
|
|
231
|
+
**SVG is rejected unless `allow_svg=True`** — SVG is XML, and XML is a script-execution
|
|
232
|
+
vector (embedded `<script>`, external entity references) that magic-byte sniffing alone
|
|
233
|
+
happily approves as "a valid file of the claimed type." When allowed, SVG dimensions are
|
|
234
|
+
read without ever touching Pillow (see `_svg_image_info`).
|
|
235
|
+
|
|
236
|
+
For raster formats, **Pillow is the header reader**: `PIL.Image.open()` is lazy and yields
|
|
237
|
+
`.size`/`.format` from the header without decoding pixel data — this *is* the
|
|
238
|
+
decompression-bomb-safe read, and it requires the `images` extra. Missing the extra raises
|
|
239
|
+
the same actionable `ImportError` pattern as `appkit.crypto`.
|
|
240
|
+
|
|
241
|
+
`seek(0)` runs in a `finally`, same as `validate_upload`, for the same corruption reason.
|
|
242
|
+
|
|
243
|
+
Raises `django.core.exceptions.ValidationError` naming which check failed; `ImportError` if
|
|
244
|
+
the `images` extra is needed and absent.
|
|
245
|
+
"""
|
|
246
|
+
limit = get_setting("MAX_UPLOAD_BYTES") if isinstance(max_bytes, _Unset) else max_bytes
|
|
247
|
+
allowed = set(_IMAGE_MIMETYPES)
|
|
248
|
+
if allow_svg:
|
|
249
|
+
allowed.add(_SVG_MIMETYPE)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
file.seek(0)
|
|
253
|
+
size = getattr(file, "size", None)
|
|
254
|
+
if size is not None and size > limit:
|
|
255
|
+
raise ValidationError(
|
|
256
|
+
f"File is too large: {size} bytes exceeds the {limit} byte limit.",
|
|
257
|
+
code="appkit_file_too_large",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
head = file.read(4096)
|
|
261
|
+
mimetype = detect_mimetype(head)
|
|
262
|
+
|
|
263
|
+
if mimetype == _SVG_MIMETYPE:
|
|
264
|
+
if not allow_svg:
|
|
265
|
+
raise ValidationError(
|
|
266
|
+
"SVG uploads are rejected by default (XML is a script-execution vector). "
|
|
267
|
+
"Pass allow_svg=True to accept them.",
|
|
268
|
+
code="appkit_svg_not_allowed",
|
|
269
|
+
)
|
|
270
|
+
return _svg_image_info(file, max_dimensions=max_dimensions)
|
|
271
|
+
|
|
272
|
+
if mimetype not in allowed:
|
|
273
|
+
raise ValidationError(
|
|
274
|
+
f"File type {mimetype!r} is not a supported image type.",
|
|
275
|
+
code="appkit_mimetype_not_allowed",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
_check_extension_agreement(getattr(file, "name", "") or "", mimetype)
|
|
279
|
+
|
|
280
|
+
image_module = _pil_image_module()
|
|
281
|
+
file.seek(0)
|
|
282
|
+
try:
|
|
283
|
+
with image_module.open(file) as img:
|
|
284
|
+
width, height = img.size
|
|
285
|
+
image_format = (img.format or "").lower()
|
|
286
|
+
except image_module.UnidentifiedImageError as exc:
|
|
287
|
+
raise ValidationError(
|
|
288
|
+
f"Could not read image data: {exc}", code="appkit_unreadable_image"
|
|
289
|
+
) from exc
|
|
290
|
+
|
|
291
|
+
if max_dimensions is not None:
|
|
292
|
+
max_width, max_height = max_dimensions
|
|
293
|
+
if width > max_width or height > max_height:
|
|
294
|
+
raise ValidationError(
|
|
295
|
+
f"Image dimensions {width}x{height} exceed the maximum "
|
|
296
|
+
f"{max_width}x{max_height}.",
|
|
297
|
+
code="appkit_dimensions_too_large",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return ImageInfo(width=width, height=height, format=image_format)
|
|
301
|
+
finally:
|
|
302
|
+
file.seek(0)
|
|
Binary file
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Persian (fa) translation catalogue for appkit.
|
|
2
|
+
# Copyright (C) 2026 Mohammad Hojjat Nikoobakht
|
|
3
|
+
# This file is distributed under the same license as the appkit package (MIT).
|
|
4
|
+
#
|
|
5
|
+
msgid ""
|
|
6
|
+
msgstr ""
|
|
7
|
+
"Project-Id-Version: appkit 1.0.0\n"
|
|
8
|
+
"Report-Msgid-Bugs-To: \n"
|
|
9
|
+
"POT-Creation-Date: 2026-08-26 00:22+0330\n"
|
|
10
|
+
"PO-Revision-Date: 2026-08-26 00:22+0330\n"
|
|
11
|
+
"Last-Translator: Mohammad Hojjat Nikoobakht\n"
|
|
12
|
+
"Language-Team: Persian\n"
|
|
13
|
+
"Language: fa\n"
|
|
14
|
+
"MIME-Version: 1.0\n"
|
|
15
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
16
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
17
|
+
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
|
18
|
+
|
|
19
|
+
msgid "App Kit"
|
|
20
|
+
msgstr "اپکیت"
|
|
21
|
+
|
|
22
|
+
msgid "Validation failed."
|
|
23
|
+
msgstr "اعتبارسنجی ناموفق بود."
|
|
24
|
+
|
|
25
|
+
msgid "Request failed."
|
|
26
|
+
msgstr "درخواست ناموفق بود."
|
|
27
|
+
|
|
28
|
+
msgid "Internal server error."
|
|
29
|
+
msgstr "خطای داخلی سرور."
|
appkit/media.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""File-location/URL formatting — absolutizing media URLs.
|
|
2
|
+
|
|
3
|
+
Kept as a separate module from ``appkit.net`` despite both starting life as "URL-ish"
|
|
4
|
+
(docs/CONTRACT.md §2 preamble). This is where the media-URL helper lives precisely *because*
|
|
5
|
+
appkit ships no ``urlpatterns`` — see docs/CONTRACT.md §10; there is no ``appkit.urls``.
|
|
6
|
+
|
|
7
|
+
Public surface (docs/CONTRACT.md §2.11):
|
|
8
|
+
|
|
9
|
+
def file_url(
|
|
10
|
+
value: FieldFile | str | None, *, request: HttpRequest | Request | None = None
|
|
11
|
+
) -> str | None: ...
|
|
12
|
+
|
|
13
|
+
def absolute_url(
|
|
14
|
+
url: str | None, *, request: HttpRequest | Request | None = None
|
|
15
|
+
) -> str | None: ...
|
|
16
|
+
|
|
17
|
+
Both raise ImproperlyConfigured only when ``request is None`` AND ``APPKIT['SITE_URL']`` is
|
|
18
|
+
unset — a host that never renders a media URL outside an active request cycle never needs
|
|
19
|
+
SITE_URL at all.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
from urllib.parse import urlparse
|
|
26
|
+
|
|
27
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
28
|
+
|
|
29
|
+
from appkit.conf import get_setting
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from django.db.models.fields.files import FieldFile
|
|
33
|
+
from django.http import HttpRequest
|
|
34
|
+
from rest_framework.request import Request
|
|
35
|
+
|
|
36
|
+
__all__ = ["absolute_url", "file_url"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_absolute(url: str) -> bool:
|
|
40
|
+
parsed = urlparse(url)
|
|
41
|
+
return bool(parsed.scheme and parsed.netloc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def file_url(
|
|
45
|
+
value: FieldFile | str | None, *, request: HttpRequest | Request | None = None
|
|
46
|
+
) -> str | None:
|
|
47
|
+
"""Absolutizes a `FieldFile`/URL string, or `None` for an unset value.
|
|
48
|
+
|
|
49
|
+
`None` and an empty `FieldFile` (Django's own `FieldFile.url` raises `ValueError` on an
|
|
50
|
+
empty field — absorbed here) both return `None`, so a serializer calling this on every
|
|
51
|
+
optional `ImageField`/`FileField` doesn't need to guard the field itself. Never raises on
|
|
52
|
+
that path; see `absolute_url` for the one path that does.
|
|
53
|
+
"""
|
|
54
|
+
if value is None:
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(value, str):
|
|
57
|
+
if not value:
|
|
58
|
+
return None
|
|
59
|
+
return absolute_url(value, request=request)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
url = value.url
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
return absolute_url(url, request=request)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def absolute_url(url: str | None, *, request: HttpRequest | Request | None = None) -> str | None:
|
|
69
|
+
"""Absolutizes `url` against `request` (or `APPKIT["SITE_URL"]` when there is none).
|
|
70
|
+
|
|
71
|
+
`None`/`""` return `None`. An already-absolute URL (a non-empty `scheme` and `netloc`,
|
|
72
|
+
e.g. an S3/CDN-backed `FieldFile`, or an off-host `MEDIA_URL`) passes through unchanged —
|
|
73
|
+
never double-prefixed.
|
|
74
|
+
|
|
75
|
+
With `request`, uses `request.build_absolute_uri(...)`, which already respects Django's
|
|
76
|
+
`SECURE_PROXY_SSL_HEADER` handling — correct in dev, staging, and prod (behind
|
|
77
|
+
`--proxy-headers`) with zero extra configuration. Without `request` (a Celery task, a
|
|
78
|
+
management command, an email template), falls back to `APPKIT["SITE_URL"]`.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ImproperlyConfigured: only when `request is None` **and** `APPKIT["SITE_URL"]` is
|
|
82
|
+
unset (the default, `""`) — naming `APPKIT["SITE_URL"]` as the fix. Silently
|
|
83
|
+
returning a relative URL in that case is exactly how a broken image link ends up in
|
|
84
|
+
a Celery-rendered email nobody notices until a customer complains.
|
|
85
|
+
"""
|
|
86
|
+
if not url:
|
|
87
|
+
return None
|
|
88
|
+
if _is_absolute(url):
|
|
89
|
+
return url
|
|
90
|
+
|
|
91
|
+
if request is not None:
|
|
92
|
+
return request.build_absolute_uri(url)
|
|
93
|
+
|
|
94
|
+
site_url = get_setting("SITE_URL")
|
|
95
|
+
if not site_url:
|
|
96
|
+
raise ImproperlyConfigured(
|
|
97
|
+
"appkit.media needs APPKIT['SITE_URL'] set to absolutize a URL with no request in "
|
|
98
|
+
"scope (e.g. from a Celery task or management command). Add APPKIT['SITE_URL'] to "
|
|
99
|
+
"your settings, or call this with a request when one is available."
|
|
100
|
+
)
|
|
101
|
+
path = url if url.startswith("/") else f"/{url}"
|
|
102
|
+
return f"{site_url.rstrip('/')}{path}"
|
appkit/mixins.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""DRF list-view response caching mixin.
|
|
2
|
+
|
|
3
|
+
Public surface (docs/CONTRACT.md §2.2), implemented in a later phase:
|
|
4
|
+
|
|
5
|
+
class CachedListMixin:
|
|
6
|
+
cache_namespace: str # REQUIRED — no default. Raises ImproperlyConfigured at first
|
|
7
|
+
# list() call if empty.
|
|
8
|
+
cache_timeout: int = UNSET # appkit.conf.UNSET — falls back to APPKIT['CACHE_TIMEOUT']
|
|
9
|
+
|
|
10
|
+
def list(self, request, *args, **kwargs) -> Response: ...
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
18
|
+
from rest_framework.response import Response
|
|
19
|
+
|
|
20
|
+
from appkit.cache import _user_cache_token, build_cache_key, cached_call
|
|
21
|
+
from appkit.conf import UNSET, _Unset
|
|
22
|
+
|
|
23
|
+
__all__ = ["CachedListMixin"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CachedListMixin:
|
|
27
|
+
"""Caches a `ListAPIView`'s serialized data per user and querystring.
|
|
28
|
+
|
|
29
|
+
Set `cache_namespace` (required — no default) and optionally `cache_timeout` (seconds,
|
|
30
|
+
falls back to `APPKIT["CACHE_TIMEOUT"]` when left `UNSET`) on the view. Caches
|
|
31
|
+
`response.data`, not the `Response` object itself: a DRF `Response` carries
|
|
32
|
+
renderer/request state that isn't meant to be pickled into a cache backend, where a plain
|
|
33
|
+
list of serialized dicts is.
|
|
34
|
+
|
|
35
|
+
**`cache_namespace` has no class-name-derived fallback**, unlike the scaffold this is
|
|
36
|
+
ported from — two apps each shipping a `NotificationListView` would collide in the host's
|
|
37
|
+
one shared Redis instance, precisely the collision `APP-DESIGN.md` §1.3 exists to prevent.
|
|
38
|
+
Raises `ImproperlyConfigured` at first `list()` call if left empty.
|
|
39
|
+
|
|
40
|
+
Usage: `class MyListView(CachedListMixin, generics.ListAPIView): ...` — the mixin must
|
|
41
|
+
precede the generic view in the MRO so it wraps `list()`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
cache_namespace: str = "" # REQUIRED — no default; empty raises at first list() call
|
|
45
|
+
cache_timeout: int | _Unset = UNSET
|
|
46
|
+
|
|
47
|
+
def _cache_key(self, request: Any) -> str:
|
|
48
|
+
if not self.cache_namespace:
|
|
49
|
+
raise ImproperlyConfigured(
|
|
50
|
+
f"{type(self).__name__}.cache_namespace is required and must be non-empty — "
|
|
51
|
+
"an unprefixed cache key is exactly the two-apps-collide scenario "
|
|
52
|
+
"APP-DESIGN.md §1.3 exists to prevent."
|
|
53
|
+
)
|
|
54
|
+
return build_cache_key(
|
|
55
|
+
self.cache_namespace,
|
|
56
|
+
_user_cache_token(request, per_user=True),
|
|
57
|
+
request.get_full_path(),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def list(self, request: Any, *args: Any, **kwargs: Any) -> Response:
|
|
61
|
+
def build() -> Any:
|
|
62
|
+
# This mixin is only ever combined with generics.ListAPIView (see the class
|
|
63
|
+
# docstring), which is where `list()` actually comes from — mypy can't see that
|
|
64
|
+
# from this class's own bases, since a plain mixin has none.
|
|
65
|
+
return super(CachedListMixin, self).list(request, *args, **kwargs).data # type: ignore[misc]
|
|
66
|
+
|
|
67
|
+
data = cached_call(self._cache_key(request), self.cache_timeout, build)
|
|
68
|
+
return Response(data)
|
appkit/money.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Integer money parsing/formatting with fixed ASCII grouping.
|
|
2
|
+
|
|
3
|
+
Flagged in docs/CONTRACT.md §11 as the contract's second-weakest module — deliberately thin
|
|
4
|
+
(a handful of pure functions, zero dependencies) rather than grown into a currency/locale
|
|
5
|
+
framework.
|
|
6
|
+
|
|
7
|
+
Public surface (docs/CONTRACT.md §2.14):
|
|
8
|
+
|
|
9
|
+
def parse_amount(value: str | int) -> int: ...
|
|
10
|
+
# Rejects float outright, raising TypeError. Raises ValueError for non-integer
|
|
11
|
+
# strings. Strips , and ٬ thousands separators.
|
|
12
|
+
|
|
13
|
+
def format_amount(value: int, *, currency: str = "") -> str: ...
|
|
14
|
+
# Fixed ASCII "," thousands separator regardless of locale — the frontend half
|
|
15
|
+
# deliberately avoids Intl.NumberFormat for the same reason (its grouping character is
|
|
16
|
+
# locale-dependent). See tests/fixtures/money-vectors.json.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from appkit.text import to_english_digits
|
|
22
|
+
|
|
23
|
+
__all__ = ["format_amount", "parse_amount"]
|
|
24
|
+
|
|
25
|
+
_THOUSANDS_SEPARATORS = (",", "٬")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_amount(value: str | int) -> int:
|
|
29
|
+
"""Parses a digit string or `int` into an `int` amount.
|
|
30
|
+
|
|
31
|
+
Accepts Persian/Arabic-Indic digits (normalised via `to_english_digits` first) and strips
|
|
32
|
+
thousands separators (`,`/`٬`) before parsing.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
TypeError: for a `float` (binary floating point can't represent most decimal currency
|
|
36
|
+
amounts exactly, so a float here is a defect in the caller, never a valid input
|
|
37
|
+
format this function should paper over) — and for a `bool`, which is an `int`
|
|
38
|
+
subclass in Python but is never a legitimate money amount (docs/CONTRACT.md §2.14
|
|
39
|
+
doesn't name `bool` explicitly; excluding it is the safer reading, since silently
|
|
40
|
+
accepting `True`/`False` as `1`/`0` would be a confusing surprise for any caller
|
|
41
|
+
that passes a boolean by mistake).
|
|
42
|
+
ValueError: for a string that isn't a valid integer after normalisation (letters,
|
|
43
|
+
multiple separators/decimal points, an empty string).
|
|
44
|
+
"""
|
|
45
|
+
if isinstance(value, bool) or isinstance(value, float):
|
|
46
|
+
raise TypeError(
|
|
47
|
+
f"parse_amount() does not accept {type(value).__name__}; pass an int or a digit string."
|
|
48
|
+
)
|
|
49
|
+
if isinstance(value, int):
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
normalized = to_english_digits(value)
|
|
53
|
+
for sep in _THOUSANDS_SEPARATORS:
|
|
54
|
+
normalized = normalized.replace(sep, "")
|
|
55
|
+
normalized = normalized.strip()
|
|
56
|
+
if not normalized or not normalized.lstrip("+-").isdigit():
|
|
57
|
+
raise ValueError(f"parse_amount() received a non-integer string: {value!r}")
|
|
58
|
+
return int(normalized)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_amount(value: int, *, currency: str = "") -> str:
|
|
62
|
+
"""Thousands-grouped string using a fixed ASCII `,` separator, regardless of locale.
|
|
63
|
+
|
|
64
|
+
`1000000` -> `"1,000,000"`, or `"1,000,000 IRT"` with `currency="IRT"`. Never raises for
|
|
65
|
+
any `int` input, including `0` and negative values (`-500` -> `"-500"`). Emits Latin digits
|
|
66
|
+
only — `to_persian_digits` is a caller's separate, explicit choice on the result.
|
|
67
|
+
"""
|
|
68
|
+
formatted = f"{value:,}"
|
|
69
|
+
return f"{formatted} {currency}" if currency else formatted
|
appkit/net.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Trust-boundary parsing of proxy headers to resolve the real client IP.
|
|
2
|
+
|
|
3
|
+
Public surface (docs/CONTRACT.md §2.10) — the module's only export:
|
|
4
|
+
|
|
5
|
+
def client_ip(request: HttpRequest | Request) -> str: ...
|
|
6
|
+
# Never raises. Uses APPKIT['TRUSTED_PROXY_COUNT'] to read X-Forwarded-For from the
|
|
7
|
+
# right (parts[-N]) — trusts only the proxy-appended entry, never the
|
|
8
|
+
# client-controlled leftmost value.
|
|
9
|
+
|
|
10
|
+
**Why from the right, not `REMOTE_ADDR`:** verified directly against the installed
|
|
11
|
+
`uvicorn` proxy-headers middleware, not assumed — with the base scaffold's documented prod
|
|
12
|
+
command (`--proxy-headers --forwarded-allow-ips "*"`), uvicorn writes the *leftmost*
|
|
13
|
+
(client-controlled) `X-Forwarded-For` entry into `scope["client"]`, i.e. into Django's own
|
|
14
|
+
`request.META["REMOTE_ADDR"]`. `REMOTE_ADDR` is therefore spoofable end-to-end in that exact
|
|
15
|
+
deployment, and this module never reads it for the answer — only as the degraded fallback when
|
|
16
|
+
the header itself can't be trusted. nginx's `$proxy_add_x_forwarded_for` *appends* its own peer
|
|
17
|
+
address to whatever it received, so with `N` trusted proxies in front, the real client is
|
|
18
|
+
`parts[-N]`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ipaddress
|
|
24
|
+
import logging
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from appkit.conf import get_setting
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from django.http import HttpRequest
|
|
31
|
+
from rest_framework.request import Request
|
|
32
|
+
|
|
33
|
+
__all__ = ["client_ip"]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_candidate(raw: str) -> str | None:
|
|
39
|
+
"""Validates a single `X-Forwarded-For` entry, stripping brackets/port where present.
|
|
40
|
+
|
|
41
|
+
Returns `None` for anything that isn't a valid IPv4/IPv6 address once normalised.
|
|
42
|
+
"""
|
|
43
|
+
raw = raw.strip()
|
|
44
|
+
if not raw:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
if raw.startswith("["):
|
|
48
|
+
# IPv6 in bracket notation, optionally with a port: "[2001:db8::1]:443" or "[2001:db8::1]".
|
|
49
|
+
end = raw.find("]")
|
|
50
|
+
if end == -1:
|
|
51
|
+
return None
|
|
52
|
+
host = raw[1:end]
|
|
53
|
+
elif raw.count(":") == 1:
|
|
54
|
+
# Exactly one colon means "host:port" (IPv4) — a bare IPv6 address always has more than
|
|
55
|
+
# one colon (or the "::" shorthand), so this never misfires on an unbracketed IPv6
|
|
56
|
+
# literal.
|
|
57
|
+
host = raw.split(":", 1)[0]
|
|
58
|
+
else:
|
|
59
|
+
host = raw
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
ipaddress.ip_address(host)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
return host
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def client_ip(request: HttpRequest | Request) -> str:
|
|
69
|
+
"""Resolves the real client IP, trusting only the proxy-appended `X-Forwarded-For` entry.
|
|
70
|
+
|
|
71
|
+
Reads `APPKIT["TRUSTED_PROXY_COUNT"]` (default `1`) trusted hops and returns the entry
|
|
72
|
+
`TRUSTED_PROXY_COUNT`-th from the **right** of the header — never the leftmost, which a
|
|
73
|
+
client can set (and pre-pend fake hops to) themselves. Never raises: an absent/empty
|
|
74
|
+
header, a header with fewer entries than `TRUSTED_PROXY_COUNT`, a malformed candidate, or a
|
|
75
|
+
non-positive `TRUSTED_PROXY_COUNT` (which would otherwise resolve `parts[-0] == parts[0]`,
|
|
76
|
+
the spoofable leftmost entry) all fall back to the connection's own remote address, with a
|
|
77
|
+
logged warning — degrading to "best available answer" rather than crashing the request.
|
|
78
|
+
"""
|
|
79
|
+
trusted_proxy_count = get_setting("TRUSTED_PROXY_COUNT")
|
|
80
|
+
fallback = request.META.get("REMOTE_ADDR", "") or ""
|
|
81
|
+
|
|
82
|
+
header = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
|
83
|
+
if not header or not header.strip():
|
|
84
|
+
return fallback
|
|
85
|
+
|
|
86
|
+
parts = [part.strip() for part in header.split(",")]
|
|
87
|
+
|
|
88
|
+
if trusted_proxy_count <= 0:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"client_ip(): TRUSTED_PROXY_COUNT is not positive (%r); falling back to the "
|
|
91
|
+
"direct connection address.",
|
|
92
|
+
trusted_proxy_count,
|
|
93
|
+
)
|
|
94
|
+
return fallback
|
|
95
|
+
|
|
96
|
+
if len(parts) < trusted_proxy_count:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"client_ip(): X-Forwarded-For has fewer entries (%d) than TRUSTED_PROXY_COUNT "
|
|
99
|
+
"(%d); falling back to the direct connection address.",
|
|
100
|
+
len(parts),
|
|
101
|
+
trusted_proxy_count,
|
|
102
|
+
)
|
|
103
|
+
return fallback
|
|
104
|
+
|
|
105
|
+
candidate = parts[-trusted_proxy_count]
|
|
106
|
+
normalized = _normalize_candidate(candidate)
|
|
107
|
+
if normalized is None:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"client_ip(): X-Forwarded-For candidate %r is not a valid IP address; falling "
|
|
110
|
+
"back to the direct connection address.",
|
|
111
|
+
candidate,
|
|
112
|
+
)
|
|
113
|
+
return fallback
|
|
114
|
+
|
|
115
|
+
return normalized
|