tyrtulia 1.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.
Files changed (83) hide show
  1. tyrtulia/__init__.py +1 -0
  2. tyrtulia/__main__.py +5 -0
  3. tyrtulia/adapters/__init__.py +1 -0
  4. tyrtulia/adapters/background.py +43 -0
  5. tyrtulia/adapters/bbj_reader.py +30 -0
  6. tyrtulia/adapters/challenges.py +21 -0
  7. tyrtulia/adapters/cli/__init__.py +1 -0
  8. tyrtulia/adapters/cli/main.py +195 -0
  9. tyrtulia/adapters/http/__init__.py +1 -0
  10. tyrtulia/adapters/http/app.py +111 -0
  11. tyrtulia/adapters/http/atom.py +64 -0
  12. tyrtulia/adapters/http/auth.py +38 -0
  13. tyrtulia/adapters/http/bodies.py +43 -0
  14. tyrtulia/adapters/http/context.py +45 -0
  15. tyrtulia/adapters/http/negotiation.py +58 -0
  16. tyrtulia/adapters/http/pagination.py +86 -0
  17. tyrtulia/adapters/http/problems.py +71 -0
  18. tyrtulia/adapters/http/ratelimit.py +155 -0
  19. tyrtulia/adapters/http/representers.py +355 -0
  20. tyrtulia/adapters/http/routes/__init__.py +30 -0
  21. tyrtulia/adapters/http/routes/auth.py +56 -0
  22. tyrtulia/adapters/http/routes/feeds.py +66 -0
  23. tyrtulia/adapters/http/routes/moderation.py +79 -0
  24. tyrtulia/adapters/http/routes/notifications.py +101 -0
  25. tyrtulia/adapters/http/routes/posts.py +115 -0
  26. tyrtulia/adapters/http/routes/root.py +11 -0
  27. tyrtulia/adapters/http/routes/schemas.py +19 -0
  28. tyrtulia/adapters/http/routes/search.py +58 -0
  29. tyrtulia/adapters/http/routes/threads.py +171 -0
  30. tyrtulia/adapters/http/routes/users.py +83 -0
  31. tyrtulia/adapters/http/server.py +82 -0
  32. tyrtulia/adapters/http/sse.py +124 -0
  33. tyrtulia/adapters/http/worker.py +44 -0
  34. tyrtulia/adapters/markdown.py +138 -0
  35. tyrtulia/adapters/peercred.py +88 -0
  36. tyrtulia/adapters/ssh.py +105 -0
  37. tyrtulia/adapters/storage/__init__.py +1 -0
  38. tyrtulia/adapters/storage/database.py +21 -0
  39. tyrtulia/adapters/storage/migrations.py +53 -0
  40. tyrtulia/adapters/storage/models.py +206 -0
  41. tyrtulia/adapters/storage/repositories.py +1056 -0
  42. tyrtulia/adapters/unix.py +17 -0
  43. tyrtulia/bootstrap.py +43 -0
  44. tyrtulia/config.py +76 -0
  45. tyrtulia/domain/__init__.py +1 -0
  46. tyrtulia/domain/bbj.py +150 -0
  47. tyrtulia/domain/clock.py +11 -0
  48. tyrtulia/domain/diff.py +30 -0
  49. tyrtulia/domain/entities.py +193 -0
  50. tyrtulia/domain/errors.py +39 -0
  51. tyrtulia/domain/ids.py +13 -0
  52. tyrtulia/domain/permissions.py +67 -0
  53. tyrtulia/domain/roles.py +32 -0
  54. tyrtulia/domain/search.py +99 -0
  55. tyrtulia/domain/usernames.py +10 -0
  56. tyrtulia/entrypoint.py +29 -0
  57. tyrtulia/migrations/0001_initial.py +141 -0
  58. tyrtulia/migrations/0002_search_titles.py +37 -0
  59. tyrtulia/migrations/__init__.py +1 -0
  60. tyrtulia/py.typed +0 -0
  61. tyrtulia/schemas/__init__.py +20 -0
  62. tyrtulia/schemas/requests.py +125 -0
  63. tyrtulia/use_cases/__init__.py +1 -0
  64. tyrtulia/use_cases/accounts.py +214 -0
  65. tyrtulia/use_cases/admin.py +133 -0
  66. tyrtulia/use_cases/auth.py +93 -0
  67. tyrtulia/use_cases/bbj_import.py +206 -0
  68. tyrtulia/use_cases/common.py +106 -0
  69. tyrtulia/use_cases/feeds.py +30 -0
  70. tyrtulia/use_cases/maintenance.py +35 -0
  71. tyrtulia/use_cases/moderation.py +129 -0
  72. tyrtulia/use_cases/notifications.py +189 -0
  73. tyrtulia/use_cases/ports.py +282 -0
  74. tyrtulia/use_cases/posts.py +251 -0
  75. tyrtulia/use_cases/search.py +29 -0
  76. tyrtulia/use_cases/services.py +32 -0
  77. tyrtulia/use_cases/sessions.py +39 -0
  78. tyrtulia/use_cases/threads.py +198 -0
  79. tyrtulia-1.0.0.dist-info/METADATA +1283 -0
  80. tyrtulia-1.0.0.dist-info/RECORD +83 -0
  81. tyrtulia-1.0.0.dist-info/WHEEL +4 -0
  82. tyrtulia-1.0.0.dist-info/entry_points.txt +3 -0
  83. tyrtulia-1.0.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,86 @@
1
+ """Opaque cursors, ``perPage`` and collection documents."""
2
+
3
+ import base64
4
+ import binascii
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+ from urllib.parse import urlencode
8
+
9
+ import msgspec
10
+ from flask import request
11
+
12
+ from tyrtulia.adapters.http.bodies import query_int
13
+ from tyrtulia.domain.entities import Page
14
+ from tyrtulia.domain.errors import validation_failed
15
+ from tyrtulia.use_cases.ports import Cursor, CursorKey
16
+
17
+ DEFAULT_PER_PAGE = 20
18
+ MAX_PER_PAGE = 100
19
+
20
+
21
+ class _CursorData(msgspec.Struct, forbid_unknown_fields=True):
22
+ k: list[str | int | float]
23
+ b: bool = False
24
+
25
+
26
+ def per_page() -> int:
27
+ value = query_int("perPage", minimum=1, maximum=MAX_PER_PAGE)
28
+ return DEFAULT_PER_PAGE if value is None else value
29
+
30
+
31
+ def encode_cursor(key: CursorKey, backwards: bool = False) -> str:
32
+ data = msgspec.json.encode({"k": key, "b": backwards} if backwards else {"k": key})
33
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
34
+
35
+
36
+ def read_cursor(size: int) -> Cursor | None:
37
+ raw = request.args.get("cursor")
38
+ if raw is None:
39
+ return None
40
+ try:
41
+ padded = raw + "=" * (-len(raw) % 4)
42
+ data = msgspec.json.decode(base64.urlsafe_b64decode(padded), type=_CursorData)
43
+ except (binascii.Error, ValueError, msgspec.DecodeError) as error:
44
+ raise validation_failed("cursor is not valid") from error
45
+ if len(data.k) != size:
46
+ raise validation_failed("cursor is not valid")
47
+ return Cursor(list(data.k), data.b)
48
+
49
+
50
+ def href(path: str, **params: Any) -> str:
51
+ query = {name: value for name, value in params.items() if value is not None}
52
+ return f"{path}?{urlencode(query)}" if query else path
53
+
54
+
55
+ def get_link(target: str, **extra: Any) -> dict[str, Any]:
56
+ return {"href": target, "method": "GET", **extra}
57
+
58
+
59
+ def collection[T](
60
+ name: str,
61
+ page: Page[T],
62
+ items: list[dict[str, Any]],
63
+ self_href: str,
64
+ *,
65
+ next_href: Callable[[T], str],
66
+ prev_href: Callable[[T], str],
67
+ links: dict[str, dict[str, Any]] | None = None,
68
+ meta: dict[str, Any] | None = None,
69
+ size: int,
70
+ ) -> dict[str, Any]:
71
+ all_links: dict[str, dict[str, Any]] = {"self": get_link(self_href)}
72
+ if page.has_next and page.items:
73
+ all_links["next"] = get_link(next_href(page.items[-1]))
74
+ if page.has_previous and page.items:
75
+ all_links["prev"] = get_link(prev_href(page.items[0]))
76
+ all_links.update(links or {})
77
+ return {
78
+ "_embedded": {name: items},
79
+ "meta": {
80
+ "perPage": size,
81
+ "hasNext": page.has_next,
82
+ "hasPrevious": page.has_previous,
83
+ **(meta or {}),
84
+ },
85
+ "_links": all_links,
86
+ }
@@ -0,0 +1,71 @@
1
+ """RFC 9457 problem documents for the error catalog."""
2
+
3
+ from typing import Any
4
+
5
+ import msgspec
6
+ from flask import Response, request
7
+
8
+ PROBLEM_TYPE = "application/problem+json"
9
+
10
+ CATALOG: dict[str, tuple[int, str]] = {
11
+ "validation-failed": (400, "The request does not match the rules"),
12
+ "invalid-query": (400, "Invalid search syntax"),
13
+ "unauthenticated": (401, "Authentication is required"),
14
+ "invalid-signature": (401, "The signature is not valid"),
15
+ "forbidden": (403, "You do not have permission for this action"),
16
+ "readonly": (403, "Your account is read-only"),
17
+ "banned": (403, "Your account is banned"),
18
+ "fields-not-allowed": (403, "A field is not allowed for you"),
19
+ "cannot-moderate-staff": (403, "Moderators and admins cannot be moderated"),
20
+ "not-found": (404, "Not found"),
21
+ "not-acceptable": (406, "Unsupported media type requested"),
22
+ "thread-closed": (409, "The thread is closed"),
23
+ "thread-has-replies": (409, "The thread has replies from other people"),
24
+ "key-already-registered": (409, "The SSH key is already registered"),
25
+ "username-conflict": (409, "An SSH key account already uses this name"),
26
+ "username-taken": (409, "The username is not available"),
27
+ "unix-identity-mismatch": (409, "The Unix identity does not match the account"),
28
+ "edit-conflict": (412, "The post was modified by someone else"),
29
+ "unsupported-media-type": (415, "Unsupported media type"),
30
+ "too-many-mentions": (422, "Too many mentions"),
31
+ "invalid-public-key": (422, "The public key is not valid"),
32
+ "invalid-target": (422, "The report target is not valid"),
33
+ "precondition-required": (428, "If-Match is required"),
34
+ "rate-limited": (429, "Too many requests"),
35
+ "too-many-connections": (429, "Too many open event streams"),
36
+ }
37
+
38
+
39
+ def problem(
40
+ code: str,
41
+ detail: str,
42
+ *,
43
+ links: dict[str, dict[str, Any]] | None = None,
44
+ headers: dict[str, str] | None = None,
45
+ ) -> Response:
46
+ status, title = CATALOG[code]
47
+ body: dict[str, Any] = {
48
+ "type": f"{request.host_url}errors/{code}",
49
+ "title": title,
50
+ "status": status,
51
+ "detail": detail,
52
+ "instance": request.path,
53
+ }
54
+ if links:
55
+ body["_links"] = links
56
+ return _response(body, status, headers)
57
+
58
+
59
+ def generic_problem(status: int, title: str, detail: str | None = None) -> Response:
60
+ """Errors outside the catalog (405, 500): ``about:blank`` as RFC 9457 allows."""
61
+ body: dict[str, Any] = {"type": "about:blank", "title": title, "status": status}
62
+ if detail:
63
+ body["detail"] = detail
64
+ return _response(body, status, None)
65
+
66
+
67
+ def _response(body: dict[str, Any], status: int, headers: dict[str, str] | None) -> Response:
68
+ response = Response(msgspec.json.encode(body), status=status, mimetype=PROBLEM_TYPE)
69
+ for name, value in (headers or {}).items():
70
+ response.headers[name] = value
71
+ return response
@@ -0,0 +1,155 @@
1
+ """In-memory sliding window rate limiting, per user and per IP.
2
+
3
+ ``GET`` and ``HEAD`` are reads, everything else is a write; requesting an
4
+ SSH challenge and logging in also count in the login limit, per IP.
5
+ Through the socket only the per-user limits apply. Every response carries
6
+ ``RateLimit`` and ``RateLimit-Policy`` (IETF httpapi rate limit headers);
7
+ a ``429`` also carries ``Retry-After``.
8
+ """
9
+
10
+ import ipaddress
11
+ import math
12
+ from collections import defaultdict, deque
13
+ from collections.abc import Callable, Sequence
14
+ from dataclasses import dataclass
15
+
16
+ from flask import Response, g, request
17
+
18
+ from tyrtulia.adapters.http.auth import READ_METHODS, via_socket
19
+ from tyrtulia.adapters.http.context import actor, config
20
+ from tyrtulia.domain.errors import DomainError
21
+
22
+ LOGIN_ENDPOINTS = frozenset({"auth.request_challenge", "auth.login"})
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class Policy:
27
+ name: str
28
+ quota: int
29
+ window: int
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Usage:
34
+ policy: Policy
35
+ remaining: int
36
+ reset: int
37
+
38
+
39
+ class SlidingWindowLimiter:
40
+ def __init__(self, now: Callable[[], float]) -> None:
41
+ self._now = now
42
+ self._hits: dict[tuple[str, str], deque[float]] = defaultdict(deque)
43
+
44
+ def hit(self, policy: Policy, keys: Sequence[str]) -> list[Usage]:
45
+ """Count one request against every key; raise if any is over the quota."""
46
+ moment = self._now()
47
+ usages = []
48
+ for key in keys:
49
+ hits = self._hits[(policy.name, key)]
50
+ while hits and hits[0] <= moment - policy.window:
51
+ hits.popleft()
52
+ reset = math.ceil(hits[0] + policy.window - moment) if hits else policy.window
53
+ if len(hits) >= policy.quota:
54
+ raise RateLimited(Usage(policy, 0, max(reset, 1)))
55
+ usages.append((hits, reset))
56
+ result = []
57
+ for hits, reset in usages:
58
+ hits.append(moment)
59
+ remaining = policy.quota - len(hits)
60
+ result.append(Usage(policy, remaining, reset if len(hits) > 1 else policy.window))
61
+ return result
62
+
63
+
64
+ class RateLimited(Exception):
65
+ def __init__(self, usage: Usage) -> None:
66
+ super().__init__(usage.policy.name)
67
+ self.usage = usage
68
+
69
+
70
+ def _trusted(address: str, proxies: Sequence[str]) -> bool:
71
+ try:
72
+ ip = ipaddress.ip_address(address)
73
+ except ValueError:
74
+ return False
75
+ for proxy in proxies:
76
+ try:
77
+ if ip in ipaddress.ip_network(proxy, strict=False):
78
+ return True
79
+ except ValueError:
80
+ continue
81
+ return False
82
+
83
+
84
+ def client_ip(remote_addr: str, forwarded_for: str | None, proxies: Sequence[str]) -> str:
85
+ """The real client IP: ``X-Forwarded-For`` is only believed from trusted proxies."""
86
+ if not forwarded_for or not _trusted(remote_addr, proxies):
87
+ return remote_addr
88
+ hops = [hop.strip() for hop in forwarded_for.split(",") if hop.strip()]
89
+ for hop in reversed(hops):
90
+ if not _trusted(hop, proxies):
91
+ return hop
92
+ return hops[0] if hops else remote_addr
93
+
94
+
95
+ def _header_value(usages: Sequence[Usage]) -> tuple[str, str]:
96
+ policies: dict[str, Usage] = {}
97
+ for usage in usages:
98
+ current = policies.get(usage.policy.name)
99
+ if current is None or usage.remaining < current.remaining:
100
+ policies[usage.policy.name] = usage
101
+ policy = ", ".join(
102
+ f'"{u.policy.name}";q={u.policy.quota};w={u.policy.window}' for u in policies.values()
103
+ )
104
+ state = ", ".join(f'"{u.policy.name}";r={u.remaining};t={u.reset}' for u in policies.values())
105
+ return policy, state
106
+
107
+
108
+ class RateLimiter:
109
+ def __init__(self, limiter: SlidingWindowLimiter) -> None:
110
+ self.limiter = limiter
111
+
112
+ def check(self) -> None:
113
+ limits = config().rate_limit
114
+ current = actor()
115
+ keys = []
116
+ if current is not None:
117
+ keys.append(f"user:{current.user.id}")
118
+ if not via_socket():
119
+ remote = request.remote_addr or "unknown"
120
+ ip = client_ip(
121
+ remote, request.headers.get("X-Forwarded-For"), config().listen.trusted_proxies
122
+ )
123
+ keys.append(f"ip:{ip}")
124
+ if request.method in READ_METHODS:
125
+ policies = [(Policy("read", limits.read_per_minute, 60), keys)]
126
+ else:
127
+ policies = [(Policy("write", limits.write_per_minute, 60), keys)]
128
+ if request.endpoint in LOGIN_ENDPOINTS and not via_socket():
129
+ ip_keys = [key for key in keys if key.startswith("ip:")]
130
+ policies.append((Policy("login", limits.login_per_15_minutes, 900), ip_keys))
131
+ usages: list[Usage] = []
132
+ try:
133
+ for policy, policy_keys in policies:
134
+ usages += self.limiter.hit(policy, policy_keys)
135
+ except RateLimited as error:
136
+ policy_header, state = _header_value([error.usage])
137
+ raise DomainError(
138
+ "rate-limited",
139
+ f"Too many requests; retry in {error.usage.reset} seconds",
140
+ headers={
141
+ "Retry-After": str(error.usage.reset),
142
+ "RateLimit-Policy": policy_header,
143
+ "RateLimit": state,
144
+ },
145
+ ) from error
146
+ g.rate_limit_usages = usages
147
+
148
+ @staticmethod
149
+ def add_headers(response: Response) -> Response:
150
+ usages = g.get("rate_limit_usages")
151
+ if usages and "RateLimit" not in response.headers:
152
+ policy, state = _header_value(usages)
153
+ response.headers["RateLimit-Policy"] = policy
154
+ response.headers["RateLimit"] = state
155
+ return response
@@ -0,0 +1,355 @@
1
+ """One representer per resource. Permissions become links; nothing else."""
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from tyrtulia.domain import permissions
7
+ from tyrtulia.domain.entities import (
8
+ Actor,
9
+ ModlogEntry,
10
+ Notification,
11
+ Post,
12
+ Revision,
13
+ SshKey,
14
+ SubscriptionLevel,
15
+ Thread,
16
+ User,
17
+ UserRef,
18
+ UserStats,
19
+ )
20
+ from tyrtulia.domain.roles import Role
21
+
22
+ type Links = dict[str, dict[str, Any]]
23
+ type Document = dict[str, Any]
24
+
25
+ DIFF_TYPE = "text/x-diff"
26
+ ATOM_TYPE = "application/atom+xml"
27
+ EVENT_STREAM_TYPE = "text/event-stream"
28
+
29
+
30
+ def iso(moment: datetime) -> str:
31
+ return moment.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
32
+
33
+
34
+ def link(href: str, method: str = "GET", **extra: Any) -> dict[str, Any]:
35
+ return {"href": href, "method": method, **extra}
36
+
37
+
38
+ def schema_link(href: str, method: str, schema: str) -> dict[str, Any]:
39
+ return link(href, method, schema=f"/schemas/{schema}")
40
+
41
+
42
+ def thread_path(thread_id: str) -> str:
43
+ return f"/threads/{thread_id}"
44
+
45
+
46
+ def post_path(post_id: str) -> str:
47
+ return f"/posts/{post_id}"
48
+
49
+
50
+ def user_path(username: str) -> str:
51
+ return f"/users/{username}"
52
+
53
+
54
+ def notification_path(notification_id: str) -> str:
55
+ return f"/notifications/{notification_id}"
56
+
57
+
58
+ def ssh_key_path(key_id: str) -> str:
59
+ return f"/users/me/ssh-keys/{key_id}"
60
+
61
+
62
+ def single_page_meta(count: int) -> dict[str, Any]:
63
+ """``meta`` of a collection that always fits in one page."""
64
+ return {"perPage": max(count, 1), "hasNext": False, "hasPrevious": False}
65
+
66
+
67
+ def report_link() -> dict[str, Any]:
68
+ return schema_link("/reports", "POST", "report")
69
+
70
+
71
+ def root(name: str, actor: Actor | None) -> Document:
72
+ links: Links = {"self": link("/"), "latest": link("/threads")}
73
+ if actor is not None:
74
+ links["watching"] = link("/threads?filter=watching")
75
+ if permissions.can_write(actor):
76
+ links["createThread"] = schema_link("/threads", "POST", "thread")
77
+ links["search"] = link("/search{?q,author,in}", templated=True)
78
+ links["user"] = link("/users/{username}", templated=True)
79
+ if actor is not None:
80
+ links["me"] = link("/users/me")
81
+ links["notifications"] = link("/notifications")
82
+ links["events"] = link("/events", type=EVENT_STREAM_TYPE)
83
+ links["modlog"] = link("/modlog")
84
+ links["alternate"] = link("/threads.atom", type=ATOM_TYPE)
85
+ if actor is None:
86
+ links["sshChallenge"] = schema_link("/ssh-challenges", "POST", "ssh-challenge")
87
+ links["login"] = schema_link("/sessions", "POST", "session")
88
+ return {"name": name, "_links": links}
89
+
90
+
91
+ def user_summary(user: UserRef) -> Document:
92
+ return {
93
+ "username": user.username,
94
+ "deactivated": user.deactivated,
95
+ "_links": {"self": link(user_path(user.username))},
96
+ }
97
+
98
+
99
+ def _user_fields(
100
+ user: User, role: Role, stats: UserStats, *, email: bool, unread: int | None = None
101
+ ) -> Document:
102
+ document: Document = {
103
+ "username": user.username,
104
+ "about": user.about,
105
+ "aboutHtml": user.about_html,
106
+ }
107
+ if email and user.email is not None:
108
+ document["email"] = user.email
109
+ document.update(
110
+ role=role.value,
111
+ deactivated=user.deactivated,
112
+ postsCount=stats.posts_count,
113
+ threadsCount=stats.threads_count,
114
+ )
115
+ if unread is not None:
116
+ document["unreadNotificationsCount"] = unread
117
+ document["createdAt"] = iso(user.created_at)
118
+ return document
119
+
120
+
121
+ def user(target: User, role: Role, stats: UserStats, actor: Actor | None) -> Document:
122
+ private = permissions.can_see_private_profile(actor, target)
123
+ document = _user_fields(target, role, stats, email=private)
124
+ path = user_path(target.username)
125
+ links: Links = {
126
+ "self": link(path),
127
+ "threads": link(f"/threads?author={target.username}"),
128
+ }
129
+ if permissions.can_report(actor, target.id):
130
+ links["report"] = report_link()
131
+ if permissions.can_moderate_user(actor, role):
132
+ if role is Role.BANNED:
133
+ links["unban"] = link(f"{path}/ban", "DELETE")
134
+ else:
135
+ links["ban"] = schema_link(f"{path}/ban", "PUT", "moderation")
136
+ # A banned user keeps the role it returns to, which can be read-only.
137
+ readonly = role is Role.READONLY or target.role_before_ban is Role.READONLY
138
+ if readonly:
139
+ links["unreadonly"] = link(f"{path}/readonly", "DELETE")
140
+ else:
141
+ links["readonly"] = schema_link(f"{path}/readonly", "PUT", "moderation")
142
+ document["_links"] = links
143
+ return document
144
+
145
+
146
+ def current_user(actor: Actor, stats: UserStats, unread: int | None) -> Document:
147
+ target = actor.user
148
+ document = _user_fields(target, actor.role, stats, email=True, unread=unread)
149
+ links: Links = {
150
+ "self": link("/users/me"),
151
+ "threads": link(f"/threads?author={target.username}"),
152
+ }
153
+ if permissions.can_write(actor):
154
+ links["edit"] = schema_link("/users/me", "PATCH", "profile")
155
+ links["sshKeys"] = link("/users/me/ssh-keys")
156
+ links["notifications"] = link("/notifications")
157
+ links["events"] = link("/events", type=EVENT_STREAM_TYPE)
158
+ if actor.session_id is not None:
159
+ links["logout"] = link("/sessions/current", "DELETE")
160
+ document["_links"] = links
161
+ return document
162
+
163
+
164
+ def ssh_key(key: SshKey, actor: Actor) -> Document:
165
+ document: Document = {
166
+ "id": key.id,
167
+ "title": key.title,
168
+ "fingerprint": key.fingerprint,
169
+ "createdAt": iso(key.created_at),
170
+ }
171
+ if key.last_used_at is not None:
172
+ document["lastUsedAt"] = iso(key.last_used_at)
173
+ links: Links = {}
174
+ if permissions.can_write(actor):
175
+ links["delete"] = link(ssh_key_path(key.id), "DELETE")
176
+ document["_links"] = links
177
+ return document
178
+
179
+
180
+ def thread(
181
+ item: Thread,
182
+ actor: Actor | None,
183
+ subscription: SubscriptionLevel | None,
184
+ first_post: Document | None = None,
185
+ ) -> Document:
186
+ path = thread_path(item.id)
187
+ document: Document = {
188
+ "id": item.id,
189
+ "title": item.title,
190
+ "pinned": item.pinned,
191
+ "closed": item.closed,
192
+ "postsCount": item.posts_count,
193
+ "highestPostNumber": item.highest_post_number,
194
+ }
195
+ if subscription is not None:
196
+ document["subscription"] = subscription.value
197
+ document["createdAt"] = iso(item.created_at)
198
+ document["lastPostedAt"] = iso(item.last_posted_at)
199
+ if item.deleted_at is not None and permissions.is_moderator(actor):
200
+ document["deletedAt"] = iso(item.deleted_at)
201
+ embedded: Document = {
202
+ "author": user_summary(item.author),
203
+ "lastPoster": user_summary(item.last_poster),
204
+ }
205
+ if first_post is not None:
206
+ embedded["firstPost"] = first_post
207
+ document["_embedded"] = embedded
208
+ links: Links = {
209
+ "self": link(path),
210
+ "posts": link(f"{path}/posts"),
211
+ }
212
+ if permissions.can_reply(actor, item):
213
+ links["reply"] = schema_link(f"{path}/posts", "POST", "post")
214
+ if permissions.can_edit_thread(actor, item):
215
+ links["edit"] = schema_link(path, "PATCH", "thread-update")
216
+ if permissions.is_moderator(actor):
217
+ links["pin"] = schema_link(path, "PATCH", "thread-update")
218
+ links["close"] = schema_link(path, "PATCH", "thread-update")
219
+ if not item.deleted and permissions.can_delete_thread(actor, item, item.others_replied):
220
+ links["delete"] = link(path, "DELETE")
221
+ if permissions.can_write(actor):
222
+ links["subscribe"] = schema_link(f"{path}/subscription", "PUT", "subscription")
223
+ if permissions.can_report(actor, item.author.id):
224
+ links["report"] = report_link()
225
+ links["alternate"] = link(f"{path}.atom", type=ATOM_TYPE)
226
+ document["_links"] = links
227
+ return document
228
+
229
+
230
+ def post(item: Post, actor: Actor | None, *, tree: bool = False) -> Document:
231
+ path = post_path(item.id)
232
+ up = link(thread_path(item.thread_id))
233
+ if item.deleted_at is not None:
234
+ tombstone: Document = {"author": user_summary(item.author)}
235
+ if tree:
236
+ tombstone["replies"] = _replies(item, actor)
237
+ return {
238
+ "id": item.id,
239
+ "postNumber": item.post_number,
240
+ "createdAt": iso(item.created_at),
241
+ "deletedAt": iso(item.deleted_at),
242
+ "_embedded": tombstone,
243
+ "_links": {"self": link(path), "up": up},
244
+ }
245
+ show_content = not item.hidden or permissions.can_see_hidden_content(actor, item)
246
+ document: Document = {"id": item.id, "postNumber": item.post_number}
247
+ if item.reply_to_post_number is not None:
248
+ document["replyToPostNumber"] = item.reply_to_post_number
249
+ if show_content:
250
+ document["body"] = item.body
251
+ document["bodyHtml"] = item.body_html
252
+ document["version"] = item.version
253
+ document["repliesCount"] = item.replies_count
254
+ document["hidden"] = item.hidden
255
+ if item.hidden and show_content and item.hidden_reason is not None:
256
+ document["hiddenReason"] = item.hidden_reason
257
+ document["createdAt"] = iso(item.created_at)
258
+ if item.edited_at is not None:
259
+ document["editedAt"] = iso(item.edited_at)
260
+ embedded: Document = {"author": user_summary(item.author)}
261
+ if tree:
262
+ embedded["replies"] = _replies(item, actor)
263
+ document["_embedded"] = embedded
264
+ links: Links = {"self": link(path), "up": up}
265
+ if item.reply_to_post_id is not None:
266
+ links["replyTo"] = link(post_path(item.reply_to_post_id))
267
+ links["replies"] = link(f"{path}/replies")
268
+ if permissions.can_see_history(actor, item):
269
+ links["version-history"] = link(f"{path}/revisions")
270
+ if permissions.can_edit_post(actor, item):
271
+ links["edit"] = schema_link(path, "PATCH", "post-update")
272
+ links["delete"] = link(path, "DELETE")
273
+ if permissions.is_moderator(actor):
274
+ rel = "unhide" if item.hidden else "hide"
275
+ links[rel] = schema_link(path, "PATCH", "post-update")
276
+ if permissions.can_report(actor, item.author.id):
277
+ links["report"] = report_link()
278
+ document["_links"] = links
279
+ return document
280
+
281
+
282
+ def _replies(item: Post, actor: Actor | None) -> list[Document]:
283
+ return [post(reply, actor, tree=True) for reply in item.replies]
284
+
285
+
286
+ def revision(
287
+ item: Revision, current: Post, actor: Actor | None, *, with_body: bool = True
288
+ ) -> Document:
289
+ path = f"{post_path(current.id)}/revisions/{item.version}"
290
+ document: Document = {"version": item.version}
291
+ if item.title is not None:
292
+ document["title"] = item.title
293
+ if with_body:
294
+ document["body"] = item.body
295
+ if item.reason is not None:
296
+ document["reason"] = item.reason
297
+ document["createdAt"] = iso(item.created_at)
298
+ document["_embedded"] = {"editor": user_summary(item.editor)}
299
+ links: Links = {
300
+ "self": link(path),
301
+ "latest-version": link(post_path(current.id)),
302
+ "diff": link(f"{path}?format=diff", type=DIFF_TYPE),
303
+ }
304
+ if item.version != current.version and permissions.can_edit_post(actor, current):
305
+ links["revert"] = link(f"{path}/revert", "POST")
306
+ document["_links"] = links
307
+ return document
308
+
309
+
310
+ def notification(item: Notification, actor: Actor) -> Document:
311
+ readonly = actor.role is Role.READONLY
312
+ document: Document = {"id": item.id, "kind": item.kind.value}
313
+ if not readonly:
314
+ document["read"] = item.read
315
+ document["excerpt"] = item.excerpt
316
+ document["createdAt"] = iso(item.created_at)
317
+ embedded: Document = {"actor": user_summary(item.actor)}
318
+ if item.thread is not None:
319
+ embedded["thread"] = {
320
+ "id": item.thread.id,
321
+ "title": item.thread.title,
322
+ "_links": {"self": link(thread_path(item.thread.id))},
323
+ }
324
+ document["_embedded"] = embedded
325
+ path = notification_path(item.id)
326
+ links: Links = {"self": link(path), "target": link(notification_target(item))}
327
+ if not readonly and permissions.can_write(actor):
328
+ links["markRead"] = schema_link(path, "PATCH", "notification-update")
329
+ document["_links"] = links
330
+ return document
331
+
332
+
333
+ def notification_target(item: Notification) -> str:
334
+ if item.post_id is not None:
335
+ return post_path(item.post_id)
336
+ if item.thread_id is not None:
337
+ return thread_path(item.thread_id)
338
+ assert item.target_user is not None
339
+ return user_path(item.target_user.username)
340
+
341
+
342
+ def modlog_entry(entry: ModlogEntry) -> Document:
343
+ document: Document = {"id": entry.id, "action": entry.action}
344
+ if entry.reason is not None:
345
+ document["reason"] = entry.reason
346
+ document["createdAt"] = iso(entry.created_at)
347
+ document["_embedded"] = {"moderator": user_summary(entry.moderator)}
348
+ if entry.target_kind == "post":
349
+ target = post_path(entry.target_id)
350
+ elif entry.target_kind == "thread":
351
+ target = thread_path(entry.target_id)
352
+ else:
353
+ target = user_path(entry.target_username or entry.target_id)
354
+ document["_links"] = {"target": link(target)}
355
+ return document