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/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