arthexis 0.1.21__py3-none-any.whl → 0.1.22__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.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

pages/views.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import base64
2
2
  import logging
3
+ import mimetypes
3
4
  from pathlib import Path
4
5
  from types import SimpleNamespace
5
6
  import datetime
@@ -21,9 +22,11 @@ from django import forms
21
22
  from django.apps import apps as django_apps
22
23
  from utils.decorators import security_group_required
23
24
  from utils.sites import get_site
24
- from django.http import Http404, HttpResponse, JsonResponse
25
+ from django.contrib.staticfiles import finders
26
+ from django.http import FileResponse, Http404, HttpResponse, JsonResponse
25
27
  from django.shortcuts import get_object_or_404, redirect, render
26
28
  from nodes.models import Node
29
+ from nodes.utils import capture_screenshot, save_screenshot
27
30
  from django.template import loader
28
31
  from django.template.response import TemplateResponse
29
32
  from django.test import RequestFactory, signals as test_signals
@@ -33,14 +36,19 @@ from django.utils.encoding import force_bytes, force_str
33
36
  from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
34
37
  from core import mailer, public_wifi
35
38
  from core.backends import TOTP_DEVICE_NAME
36
- from django.utils.translation import gettext as _
39
+ from django.utils.translation import get_language, gettext as _
40
+
41
+ try: # pragma: no cover - compatibility shim for Django versions without constant
42
+ from django.utils.translation import LANGUAGE_SESSION_KEY
43
+ except ImportError: # pragma: no cover - fallback when constant is unavailable
44
+ LANGUAGE_SESSION_KEY = "_language"
37
45
  from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
38
46
  from django.views.decorators.http import require_POST
39
47
  from django.core.cache import cache
40
48
  from django.views.decorators.cache import never_cache
41
- from django.utils.cache import patch_vary_headers
42
- from django.core.exceptions import PermissionDenied
43
- from django.utils.text import slugify
49
+ from django.utils.cache import patch_cache_control, patch_vary_headers
50
+ from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
51
+ from django.utils.text import slugify, Truncator
44
52
  from django.core.validators import EmailValidator
45
53
  from django.db.models import Q
46
54
  from core.models import (
@@ -48,6 +56,7 @@ from core.models import (
48
56
  ClientReport,
49
57
  ClientReportSchedule,
50
58
  SecurityGroup,
59
+ Todo,
51
60
  )
52
61
 
53
62
  try: # pragma: no cover - optional dependency guard
@@ -58,16 +67,34 @@ except ImportError: # pragma: no cover - handled gracefully in views
58
67
  CalledProcessError = ExecutableNotFound = None
59
68
 
60
69
  import markdown
70
+ from django.utils._os import safe_join
61
71
 
62
72
 
63
73
  MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
64
74
 
75
+ MARKDOWN_IMAGE_PATTERN = re.compile(
76
+ r"(?P<prefix><img\b[^>]*\bsrc=[\"\'])(?P<scheme>(?:static|work))://(?P<path>[^\"\']+)(?P<suffix>[\"\'])",
77
+ re.IGNORECASE,
78
+ )
79
+
80
+ ALLOWED_IMAGE_EXTENSIONS = {
81
+ ".apng",
82
+ ".avif",
83
+ ".gif",
84
+ ".jpg",
85
+ ".jpeg",
86
+ ".png",
87
+ ".svg",
88
+ ".webp",
89
+ }
90
+
65
91
 
66
92
  def _render_markdown_with_toc(text: str) -> tuple[str, str]:
67
93
  """Render ``text`` to HTML and return the HTML and stripped TOC."""
68
94
 
69
95
  md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
70
96
  html = md.convert(text)
97
+ html = _rewrite_markdown_asset_links(html)
71
98
  toc_html = md.toc
72
99
  toc_html = _strip_toc_wrapper(toc_html)
73
100
  return html, toc_html
@@ -82,6 +109,86 @@ def _strip_toc_wrapper(toc_html: str) -> str:
82
109
  if toc_html.endswith("</div>"):
83
110
  toc_html = toc_html[: -len("</div>")]
84
111
  return toc_html.strip()
112
+
113
+
114
+ def _rewrite_markdown_asset_links(html: str) -> str:
115
+ """Rewrite asset links that reference local asset schemes."""
116
+
117
+ def _replace(match: re.Match[str]) -> str:
118
+ scheme = match.group("scheme").lower()
119
+ asset_path = match.group("path").lstrip("/")
120
+ if not asset_path:
121
+ return match.group(0)
122
+ extension = Path(asset_path).suffix.lower()
123
+ if extension not in ALLOWED_IMAGE_EXTENSIONS:
124
+ return match.group(0)
125
+ try:
126
+ asset_url = reverse(
127
+ "pages:readme-asset",
128
+ kwargs={"source": scheme, "asset": asset_path},
129
+ )
130
+ except NoReverseMatch:
131
+ return match.group(0)
132
+ return f"{match.group('prefix')}{escape(asset_url)}{match.group('suffix')}"
133
+
134
+ return MARKDOWN_IMAGE_PATTERN.sub(_replace, html)
135
+
136
+
137
+ def _resolve_static_asset(path: str) -> Path:
138
+ normalized = path.lstrip("/")
139
+ if not normalized:
140
+ raise Http404("Asset not found")
141
+ resolved = finders.find(normalized)
142
+ if not resolved:
143
+ raise Http404("Asset not found")
144
+ if isinstance(resolved, (list, tuple)):
145
+ resolved = resolved[0]
146
+ file_path = Path(resolved)
147
+ if file_path.is_dir():
148
+ raise Http404("Asset not found")
149
+ return file_path
150
+
151
+
152
+ def _resolve_work_asset(user, path: str) -> Path:
153
+ if not (user and getattr(user, "is_authenticated", False)):
154
+ raise PermissionDenied
155
+ normalized = path.lstrip("/")
156
+ if not normalized:
157
+ raise Http404("Asset not found")
158
+ username = getattr(user, "get_username", None)
159
+ if callable(username):
160
+ username = username()
161
+ else:
162
+ username = getattr(user, "username", "")
163
+ username_component = Path(str(username or user.pk)).name
164
+ base_work = Path(settings.BASE_DIR) / "work"
165
+ try:
166
+ user_dir = Path(safe_join(str(base_work), username_component))
167
+ asset_path = Path(safe_join(str(user_dir), normalized))
168
+ except SuspiciousFileOperation as exc:
169
+ logger.warning("Rejected suspicious work asset path: %s", normalized, exc_info=exc)
170
+ raise Http404("Asset not found") from exc
171
+ try:
172
+ user_dir_resolved = user_dir.resolve(strict=True)
173
+ except FileNotFoundError as exc:
174
+ logger.warning(
175
+ "Work directory missing for asset request: %s", user_dir, exc_info=exc
176
+ )
177
+ raise Http404("Asset not found") from exc
178
+ try:
179
+ asset_resolved = asset_path.resolve(strict=True)
180
+ except FileNotFoundError as exc:
181
+ raise Http404("Asset not found") from exc
182
+ try:
183
+ asset_resolved.relative_to(user_dir_resolved)
184
+ except ValueError as exc:
185
+ logger.warning(
186
+ "Rejected work asset outside directory: %s", asset_resolved, exc_info=exc
187
+ )
188
+ raise Http404("Asset not found") from exc
189
+ if asset_resolved.is_dir():
190
+ raise Http404("Asset not found")
191
+ return asset_resolved
85
192
  from pages.utils import landing
86
193
  from core.liveupdate import live_update
87
194
  from django_otp import login as otp_login
@@ -487,19 +594,33 @@ def _locate_readme_document(role, doc: str | None, lang: str) -> SimpleNamespace
487
594
  continue
488
595
  candidates.append(candidate)
489
596
  else:
597
+ default_readme = readme_base / "README.md"
598
+ root_default: Path | None = None
490
599
  if lang:
491
600
  candidates.append(readme_base / f"README.{lang}.md")
492
601
  short = lang.split("-")[0]
493
602
  if short != lang:
494
603
  candidates.append(readme_base / f"README.{short}.md")
495
- candidates.append(readme_base / "README.md")
496
604
  if readme_base != root_base:
605
+ candidates.append(default_readme)
497
606
  if lang:
498
607
  candidates.append(root_base / f"README.{lang}.md")
499
608
  short = lang.split("-")[0]
500
609
  if short != lang:
501
610
  candidates.append(root_base / f"README.{short}.md")
502
- candidates.append(root_base / "README.md")
611
+ root_default = root_base / "README.md"
612
+ else:
613
+ root_default = default_readme
614
+ locale_base = root_base / "locale"
615
+ if locale_base.exists():
616
+ if lang:
617
+ candidates.append(locale_base / f"README.{lang}.md")
618
+ short = lang.split("-")[0]
619
+ if short != lang:
620
+ candidates.append(locale_base / f"README.{short}.md")
621
+ candidates.append(locale_base / "README.md")
622
+ if root_default is not None:
623
+ candidates.append(root_default)
503
624
 
504
625
  readme_file = next((p for p in candidates if p.exists()), None)
505
626
  if readme_file is None:
@@ -552,6 +673,44 @@ def _render_readme(request, role, doc: str | None = None):
552
673
  return response
553
674
 
554
675
 
676
+ def readme_asset(request, source: str, asset: str):
677
+ source_normalized = (source or "").lower()
678
+ if source_normalized == "static":
679
+ file_path = _resolve_static_asset(asset)
680
+ elif source_normalized == "work":
681
+ file_path = _resolve_work_asset(getattr(request, "user", None), asset)
682
+ else:
683
+ raise Http404("Asset not found")
684
+
685
+ if not file_path.exists() or not file_path.is_file():
686
+ raise Http404("Asset not found")
687
+
688
+ extension = file_path.suffix.lower()
689
+ if extension not in ALLOWED_IMAGE_EXTENSIONS:
690
+ raise Http404("Asset not found")
691
+
692
+ try:
693
+ file_handle = file_path.open("rb")
694
+ except OSError as exc: # pragma: no cover - unexpected filesystem error
695
+ logger.warning("Unable to open asset %s", file_path, exc_info=exc)
696
+ raise Http404("Asset not found") from exc
697
+
698
+ content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
699
+ response = FileResponse(file_handle, content_type=content_type)
700
+ try:
701
+ response["Content-Length"] = str(file_path.stat().st_size)
702
+ except OSError: # pragma: no cover - filesystem race
703
+ pass
704
+
705
+ if source_normalized == "work":
706
+ patch_cache_control(response, private=True, no_store=True)
707
+ patch_vary_headers(response, ["Cookie"])
708
+ else:
709
+ patch_cache_control(response, public=True, max_age=3600)
710
+
711
+ return response
712
+
713
+
555
714
  class MarkdownDocumentForm(forms.Form):
556
715
  content = forms.CharField(
557
716
  widget=forms.Textarea(
@@ -1269,6 +1428,25 @@ def client_report(request):
1269
1428
  return render(request, "pages/client_report.html", context)
1270
1429
 
1271
1430
 
1431
+ def _get_request_language_code(request) -> str:
1432
+ language_code = ""
1433
+ if hasattr(request, "session"):
1434
+ language_code = request.session.get(LANGUAGE_SESSION_KEY, "")
1435
+ if not language_code:
1436
+ cookie_name = getattr(settings, "LANGUAGE_COOKIE_NAME", "django_language")
1437
+ language_code = request.COOKIES.get(cookie_name, "")
1438
+ if not language_code:
1439
+ language_code = getattr(request, "LANGUAGE_CODE", "") or ""
1440
+ if not language_code:
1441
+ language_code = get_language() or ""
1442
+
1443
+ language_code = language_code.strip()
1444
+ if not language_code:
1445
+ return ""
1446
+
1447
+ return language_code.replace("_", "-").lower()[:15]
1448
+
1449
+
1272
1450
  @require_POST
1273
1451
  def submit_user_story(request):
1274
1452
  throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
@@ -1290,12 +1468,12 @@ def submit_user_story(request):
1290
1468
  )
1291
1469
 
1292
1470
  data = request.POST.copy()
1293
- if request.user.is_authenticated and not data.get("name"):
1471
+ if request.user.is_authenticated:
1294
1472
  data["name"] = request.user.get_username()[:40]
1295
1473
  if not data.get("path"):
1296
1474
  data["path"] = request.get_full_path()
1297
1475
 
1298
- form = UserStoryForm(data)
1476
+ form = UserStoryForm(data, user=request.user)
1299
1477
  if request.user.is_authenticated:
1300
1478
  form.instance.user = request.user
1301
1479
 
@@ -1304,8 +1482,7 @@ def submit_user_story(request):
1304
1482
  if request.user.is_authenticated:
1305
1483
  story.user = request.user
1306
1484
  story.owner = request.user
1307
- if not story.name:
1308
- story.name = request.user.get_username()[:40]
1485
+ story.name = request.user.get_username()[:40]
1309
1486
  if not story.name:
1310
1487
  story.name = str(_("Anonymous"))[:40]
1311
1488
  story.path = (story.path or request.get_full_path())[:500]
@@ -1313,7 +1490,83 @@ def submit_user_story(request):
1313
1490
  story.user_agent = request.META.get("HTTP_USER_AGENT", "")
1314
1491
  story.ip_address = client_ip or None
1315
1492
  story.is_user_data = True
1493
+ language_code = _get_request_language_code(request)
1494
+ if language_code:
1495
+ story.language_code = language_code
1316
1496
  story.save()
1497
+ if request.user.is_authenticated and request.user.is_superuser:
1498
+ comment_text = (story.comments or "").strip()
1499
+ prefix = "Triage "
1500
+ request_field = Todo._meta.get_field("request")
1501
+ available_length = max(request_field.max_length - len(prefix), 0)
1502
+ if available_length > 0 and comment_text:
1503
+ summary = Truncator(comment_text).chars(
1504
+ available_length, truncate="…"
1505
+ )
1506
+ else:
1507
+ summary = comment_text[:available_length]
1508
+ todo_request = f"{prefix}{summary}".strip()
1509
+ user_is_authenticated = request.user.is_authenticated
1510
+ node = Node.get_local()
1511
+ existing_todo = (
1512
+ Todo.objects.filter(request__iexact=todo_request, is_deleted=False)
1513
+ .order_by("pk")
1514
+ .first()
1515
+ )
1516
+ if existing_todo:
1517
+ update_fields: set[str] = set()
1518
+ if node and existing_todo.origin_node_id != node.pk:
1519
+ existing_todo.origin_node = node
1520
+ update_fields.add("origin_node")
1521
+ if existing_todo.original_user_id != request.user.pk:
1522
+ existing_todo.original_user = request.user
1523
+ update_fields.add("original_user")
1524
+ if (
1525
+ existing_todo.original_user_is_authenticated
1526
+ != user_is_authenticated
1527
+ ):
1528
+ existing_todo.original_user_is_authenticated = (
1529
+ user_is_authenticated
1530
+ )
1531
+ update_fields.add("original_user_is_authenticated")
1532
+ if not existing_todo.is_user_data:
1533
+ existing_todo.is_user_data = True
1534
+ update_fields.add("is_user_data")
1535
+ if update_fields:
1536
+ existing_todo.save(update_fields=tuple(update_fields))
1537
+ else:
1538
+ Todo.objects.create(
1539
+ request=todo_request,
1540
+ origin_node=node,
1541
+ original_user=request.user,
1542
+ original_user_is_authenticated=user_is_authenticated,
1543
+ is_user_data=True,
1544
+ )
1545
+ if story.take_screenshot:
1546
+ screenshot_url = request.META.get("HTTP_REFERER", "")
1547
+ parsed = urlparse(screenshot_url)
1548
+ if not (parsed.scheme and parsed.netloc):
1549
+ target_path = story.path or request.get_full_path() or "/"
1550
+ screenshot_url = request.build_absolute_uri(target_path)
1551
+ try:
1552
+ screenshot_path = capture_screenshot(screenshot_url)
1553
+ except Exception: # pragma: no cover - best effort capture
1554
+ logger.exception("Failed to capture screenshot for user story %s", story.pk)
1555
+ else:
1556
+ try:
1557
+ sample = save_screenshot(
1558
+ screenshot_path,
1559
+ method="USER_STORY",
1560
+ user=story.user if story.user_id else None,
1561
+ )
1562
+ except Exception: # pragma: no cover - best effort persistence
1563
+ logger.exception(
1564
+ "Failed to persist screenshot for user story %s", story.pk
1565
+ )
1566
+ else:
1567
+ if sample is not None:
1568
+ story.screenshot = sample
1569
+ story.save(update_fields=["screenshot"])
1317
1570
  return JsonResponse({"success": True})
1318
1571
 
1319
1572
  return JsonResponse({"success": False, "errors": form.errors}, status=400)