arthexis 0.1.20__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(
@@ -710,11 +869,6 @@ def release_checklist(request):
710
869
  return response
711
870
 
712
871
 
713
- @csrf_exempt
714
- def datasette_auth(request):
715
- if request.user.is_authenticated:
716
- return HttpResponse("OK")
717
- return HttpResponse(status=401)
718
872
 
719
873
 
720
874
  class CustomLoginView(LoginView):
@@ -1274,6 +1428,25 @@ def client_report(request):
1274
1428
  return render(request, "pages/client_report.html", context)
1275
1429
 
1276
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
+
1277
1450
  @require_POST
1278
1451
  def submit_user_story(request):
1279
1452
  throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
@@ -1295,12 +1468,12 @@ def submit_user_story(request):
1295
1468
  )
1296
1469
 
1297
1470
  data = request.POST.copy()
1298
- if request.user.is_authenticated and not data.get("name"):
1471
+ if request.user.is_authenticated:
1299
1472
  data["name"] = request.user.get_username()[:40]
1300
1473
  if not data.get("path"):
1301
1474
  data["path"] = request.get_full_path()
1302
1475
 
1303
- form = UserStoryForm(data)
1476
+ form = UserStoryForm(data, user=request.user)
1304
1477
  if request.user.is_authenticated:
1305
1478
  form.instance.user = request.user
1306
1479
 
@@ -1309,8 +1482,7 @@ def submit_user_story(request):
1309
1482
  if request.user.is_authenticated:
1310
1483
  story.user = request.user
1311
1484
  story.owner = request.user
1312
- if not story.name:
1313
- story.name = request.user.get_username()[:40]
1485
+ story.name = request.user.get_username()[:40]
1314
1486
  if not story.name:
1315
1487
  story.name = str(_("Anonymous"))[:40]
1316
1488
  story.path = (story.path or request.get_full_path())[:500]
@@ -1318,7 +1490,83 @@ def submit_user_story(request):
1318
1490
  story.user_agent = request.META.get("HTTP_USER_AGENT", "")
1319
1491
  story.ip_address = client_ip or None
1320
1492
  story.is_user_data = True
1493
+ language_code = _get_request_language_code(request)
1494
+ if language_code:
1495
+ story.language_code = language_code
1321
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"])
1322
1570
  return JsonResponse({"success": True})
1323
1571
 
1324
1572
  return JsonResponse({"success": False, "errors": form.errors}, status=400)
core/workgroup_urls.py DELETED
@@ -1,17 +0,0 @@
1
- """URL routes for assistant profile endpoints."""
2
-
3
- from django.urls import path
4
-
5
- from . import workgroup_views as views
6
-
7
- app_name = "workgroup"
8
-
9
- urlpatterns = [
10
- path(
11
- "assistant-profiles/<int:user_id>/",
12
- views.issue_key,
13
- name="assistantprofile-issue",
14
- ),
15
- path("assistant/test/", views.assistant_test, name="assistant-test"),
16
- path("chat/", views.chat, name="chat"),
17
- ]
core/workgroup_views.py DELETED
@@ -1,94 +0,0 @@
1
- """REST endpoints for AssistantProfile issuance and authentication."""
2
-
3
- from __future__ import annotations
4
-
5
- from functools import wraps
6
-
7
- from django.apps import apps
8
- from django.contrib.auth import get_user_model
9
- from django.forms.models import model_to_dict
10
- from django.http import HttpResponse, JsonResponse
11
- from django.views.decorators.csrf import csrf_exempt
12
- from django.views.decorators.http import require_GET, require_POST
13
-
14
- from .models import AssistantProfile, hash_key
15
-
16
-
17
- @csrf_exempt
18
- @require_POST
19
- def issue_key(request, user_id: int) -> JsonResponse:
20
- """Issue a new ``user_key`` for ``user_id``.
21
-
22
- The response reveals the plain key once. Store only the hash server-side.
23
- """
24
-
25
- user = get_user_model().objects.get(pk=user_id)
26
- profile, key = AssistantProfile.issue_key(user)
27
- return JsonResponse({"user_id": user_id, "user_key": key})
28
-
29
-
30
- def authenticate(view_func):
31
- """View decorator that validates the ``Authorization`` header."""
32
-
33
- @wraps(view_func)
34
- def wrapper(request, *args, **kwargs):
35
- header = request.META.get("HTTP_AUTHORIZATION", "")
36
- if not header.startswith("Bearer "):
37
- return HttpResponse(status=401)
38
-
39
- key_hash = hash_key(header.split(" ", 1)[1])
40
- try:
41
- profile = AssistantProfile.objects.get(
42
- user_key_hash=key_hash, is_active=True
43
- )
44
- except AssistantProfile.DoesNotExist:
45
- return HttpResponse(status=401)
46
-
47
- profile.touch()
48
- request.assistant_profile = profile
49
- request.chat_profile = profile
50
- return view_func(request, *args, **kwargs)
51
-
52
- return wrapper
53
-
54
-
55
- @require_GET
56
- @authenticate
57
- def assistant_test(request):
58
- """Return a simple greeting to confirm authentication."""
59
-
60
- profile = getattr(request, "assistant_profile", None)
61
- user_id = profile.user_id if profile else None
62
- return JsonResponse({"message": f"Hello from user {user_id}"})
63
-
64
-
65
- @require_GET
66
- @authenticate
67
- def chat(request):
68
- """Return serialized data from any model.
69
-
70
- Clients must provide ``model`` as ``app_label.ModelName`` and may include a
71
- ``pk`` to fetch a specific record. When ``pk`` is omitted, the view returns
72
- up to 100 records.
73
- """
74
-
75
- model_label = request.GET.get("model")
76
- if not model_label:
77
- return JsonResponse({"error": "model parameter required"}, status=400)
78
- try:
79
- model = apps.get_model(model_label)
80
- except LookupError:
81
- return JsonResponse({"error": "unknown model"}, status=400)
82
-
83
- qs = model.objects.all()
84
- pk = request.GET.get("pk")
85
- if pk is not None:
86
- try:
87
- obj = qs.get(pk=pk)
88
- except model.DoesNotExist:
89
- return JsonResponse({"error": "object not found"}, status=404)
90
- data = model_to_dict(obj)
91
- else:
92
- data = [model_to_dict(o) for o in qs[:100]]
93
-
94
- return JsonResponse({"data": data})