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.
- tyrtulia/__init__.py +1 -0
- tyrtulia/__main__.py +5 -0
- tyrtulia/adapters/__init__.py +1 -0
- tyrtulia/adapters/background.py +43 -0
- tyrtulia/adapters/bbj_reader.py +30 -0
- tyrtulia/adapters/challenges.py +21 -0
- tyrtulia/adapters/cli/__init__.py +1 -0
- tyrtulia/adapters/cli/main.py +195 -0
- tyrtulia/adapters/http/__init__.py +1 -0
- tyrtulia/adapters/http/app.py +111 -0
- tyrtulia/adapters/http/atom.py +64 -0
- tyrtulia/adapters/http/auth.py +38 -0
- tyrtulia/adapters/http/bodies.py +43 -0
- tyrtulia/adapters/http/context.py +45 -0
- tyrtulia/adapters/http/negotiation.py +58 -0
- tyrtulia/adapters/http/pagination.py +86 -0
- tyrtulia/adapters/http/problems.py +71 -0
- tyrtulia/adapters/http/ratelimit.py +155 -0
- tyrtulia/adapters/http/representers.py +355 -0
- tyrtulia/adapters/http/routes/__init__.py +30 -0
- tyrtulia/adapters/http/routes/auth.py +56 -0
- tyrtulia/adapters/http/routes/feeds.py +66 -0
- tyrtulia/adapters/http/routes/moderation.py +79 -0
- tyrtulia/adapters/http/routes/notifications.py +101 -0
- tyrtulia/adapters/http/routes/posts.py +115 -0
- tyrtulia/adapters/http/routes/root.py +11 -0
- tyrtulia/adapters/http/routes/schemas.py +19 -0
- tyrtulia/adapters/http/routes/search.py +58 -0
- tyrtulia/adapters/http/routes/threads.py +171 -0
- tyrtulia/adapters/http/routes/users.py +83 -0
- tyrtulia/adapters/http/server.py +82 -0
- tyrtulia/adapters/http/sse.py +124 -0
- tyrtulia/adapters/http/worker.py +44 -0
- tyrtulia/adapters/markdown.py +138 -0
- tyrtulia/adapters/peercred.py +88 -0
- tyrtulia/adapters/ssh.py +105 -0
- tyrtulia/adapters/storage/__init__.py +1 -0
- tyrtulia/adapters/storage/database.py +21 -0
- tyrtulia/adapters/storage/migrations.py +53 -0
- tyrtulia/adapters/storage/models.py +206 -0
- tyrtulia/adapters/storage/repositories.py +1056 -0
- tyrtulia/adapters/unix.py +17 -0
- tyrtulia/bootstrap.py +43 -0
- tyrtulia/config.py +76 -0
- tyrtulia/domain/__init__.py +1 -0
- tyrtulia/domain/bbj.py +150 -0
- tyrtulia/domain/clock.py +11 -0
- tyrtulia/domain/diff.py +30 -0
- tyrtulia/domain/entities.py +193 -0
- tyrtulia/domain/errors.py +39 -0
- tyrtulia/domain/ids.py +13 -0
- tyrtulia/domain/permissions.py +67 -0
- tyrtulia/domain/roles.py +32 -0
- tyrtulia/domain/search.py +99 -0
- tyrtulia/domain/usernames.py +10 -0
- tyrtulia/entrypoint.py +29 -0
- tyrtulia/migrations/0001_initial.py +141 -0
- tyrtulia/migrations/0002_search_titles.py +37 -0
- tyrtulia/migrations/__init__.py +1 -0
- tyrtulia/py.typed +0 -0
- tyrtulia/schemas/__init__.py +20 -0
- tyrtulia/schemas/requests.py +125 -0
- tyrtulia/use_cases/__init__.py +1 -0
- tyrtulia/use_cases/accounts.py +214 -0
- tyrtulia/use_cases/admin.py +133 -0
- tyrtulia/use_cases/auth.py +93 -0
- tyrtulia/use_cases/bbj_import.py +206 -0
- tyrtulia/use_cases/common.py +106 -0
- tyrtulia/use_cases/feeds.py +30 -0
- tyrtulia/use_cases/maintenance.py +35 -0
- tyrtulia/use_cases/moderation.py +129 -0
- tyrtulia/use_cases/notifications.py +189 -0
- tyrtulia/use_cases/ports.py +282 -0
- tyrtulia/use_cases/posts.py +251 -0
- tyrtulia/use_cases/search.py +29 -0
- tyrtulia/use_cases/services.py +32 -0
- tyrtulia/use_cases/sessions.py +39 -0
- tyrtulia/use_cases/threads.py +198 -0
- tyrtulia-1.0.0.dist-info/METADATA +1283 -0
- tyrtulia-1.0.0.dist-info/RECORD +83 -0
- tyrtulia-1.0.0.dist-info/WHEEL +4 -0
- tyrtulia-1.0.0.dist-info/entry_points.txt +3 -0
- 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
|