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
tyrtulia/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Tyrtulia: a headless forum for small multi-user servers."""
tyrtulia/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """``python -m tyrtulia``: the same as the ``tyrtulia`` console script."""
2
+
3
+ from tyrtulia.entrypoint import main
4
+
5
+ main()
@@ -0,0 +1 @@
1
+ """Adapters: HTTP, storage and CLI."""
@@ -0,0 +1,43 @@
1
+ """Work that runs inside the worker: periodic tasks (MiniHuey)."""
2
+
3
+ import logging
4
+
5
+ from flask import Flask
6
+ from huey import crontab
7
+ from huey.contrib.mini import MiniHuey
8
+
9
+ from tyrtulia.adapters.http.context import SERVICES_KEY
10
+ from tyrtulia.use_cases import maintenance
11
+ from tyrtulia.use_cases.services import Services
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ START_KEY = "tyrtulia.start"
16
+
17
+
18
+ def attach_background(app: Flask) -> None:
19
+ """Register the function that ``post_worker_init`` calls to start the background work."""
20
+ svc: Services = app.extensions[SERVICES_KEY]
21
+
22
+ def start() -> None:
23
+ huey = MiniHuey("tyrtulia")
24
+
25
+ @huey.periodic_task(crontab(minute="*/10"))
26
+ def purge_events() -> None:
27
+ removed = maintenance.purge_events(svc)
28
+ logger.info("Purged %d events", removed)
29
+
30
+ @huey.periodic_task(crontab(minute="5"))
31
+ def expire_sessions() -> None:
32
+ removed = maintenance.expire_sessions(svc)
33
+ logger.info("Expired %d sessions", removed)
34
+
35
+ @huey.periodic_task(crontab(minute="*/15"))
36
+ def deactivate_missing_unix_users() -> None:
37
+ for username in maintenance.deactivate_missing_unix_users(svc):
38
+ logger.info("Deactivated %s: the Unix user no longer exists", username)
39
+
40
+ huey.start()
41
+ app.extensions["tyrtulia.huey"] = huey
42
+
43
+ app.extensions[START_KEY] = start
@@ -0,0 +1,30 @@
1
+ """Read a BBJ SQLite database (tables ``users``, ``threads`` and ``messages``)."""
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+ from tyrtulia.domain.bbj import BbjDatabase, BbjMessage, BbjThread, BbjUser
7
+
8
+
9
+ def read_bbj(path: Path) -> BbjDatabase:
10
+ connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
11
+ try:
12
+ users = [
13
+ BbjUser(row[0], row[1], row[2] or "", float(row[3] or 0))
14
+ for row in connection.execute("SELECT user_id, user_name, bio, created FROM users")
15
+ ]
16
+ threads = [
17
+ BbjThread(row[0], row[1], row[2], float(row[3] or 0), bool(row[4]), row[5] or row[1])
18
+ for row in connection.execute(
19
+ "SELECT thread_id, author, title, created, pinned, last_author FROM threads"
20
+ )
21
+ ]
22
+ messages = [
23
+ BbjMessage(row[0], int(row[1]), row[2], float(row[3] or 0), row[4] or "", bool(row[5]))
24
+ for row in connection.execute(
25
+ "SELECT thread_id, post_id, author, created, body, send_raw FROM messages"
26
+ )
27
+ ]
28
+ finally:
29
+ connection.close()
30
+ return BbjDatabase(users, threads, messages)
@@ -0,0 +1,21 @@
1
+ """SSH challenges live in memory: they last two minutes and there is one process."""
2
+
3
+ from datetime import datetime
4
+
5
+ from tyrtulia.use_cases.ports import PendingChallenge
6
+
7
+
8
+ class MemoryChallengeStore:
9
+ def __init__(self) -> None:
10
+ self._pending: dict[str, PendingChallenge] = {}
11
+
12
+ def add(self, challenge: str, pending: PendingChallenge) -> None:
13
+ self._pending[challenge] = pending
14
+
15
+ def take(self, challenge: str) -> PendingChallenge | None:
16
+ return self._pending.pop(challenge, None)
17
+
18
+ def purge(self, moment: datetime) -> None:
19
+ expired = [key for key, value in self._pending.items() if value.expires_at <= moment]
20
+ for key in expired:
21
+ del self._pending[key]
@@ -0,0 +1 @@
1
+ """Administration CLI (Typer)."""
@@ -0,0 +1,195 @@
1
+ """``tyrtulia``: the administration CLI."""
2
+
3
+ import logging
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from tyrtulia.config import Config, ConfigError, load_config, resolve_path
12
+ from tyrtulia.domain.errors import DomainError
13
+ from tyrtulia.use_cases.services import Services
14
+
15
+ app = typer.Typer(help="Tyrtulia administration CLI.", no_args_is_help=True)
16
+ account_app = typer.Typer(help="SSH key accounts.", no_args_is_help=True)
17
+ identity_app = typer.Typer(help="Unix identity problems.", no_args_is_help=True)
18
+ app.add_typer(account_app, name="account")
19
+ app.add_typer(identity_app, name="identity")
20
+
21
+ ConfigOption = Annotated[
22
+ str | None,
23
+ typer.Option(
24
+ "--config",
25
+ help="Configuration file (default: TYRTULIA_CONFIG or /etc/tyrtulia/config.toml).",
26
+ ),
27
+ ]
28
+ KeyOption = Annotated[
29
+ Path, typer.Option("--key", help="Public key file (an authorized_keys line).", exists=True)
30
+ ]
31
+
32
+
33
+ class State:
34
+ config_path: Path = Path("/etc/tyrtulia/config.toml")
35
+
36
+
37
+ state = State()
38
+
39
+
40
+ @app.callback()
41
+ def main(config: ConfigOption = None) -> None:
42
+ state.config_path = resolve_path(config)
43
+
44
+
45
+ def fail(message: str) -> typer.Exit:
46
+ typer.echo(message, err=True)
47
+ return typer.Exit(1)
48
+
49
+
50
+ def read_config() -> Config:
51
+ try:
52
+ return load_config(state.config_path)
53
+ except ConfigError as error:
54
+ raise fail(str(error)) from error
55
+
56
+
57
+ @contextmanager
58
+ def open_services() -> Iterator[Services]:
59
+ """Services over the configured database, which must be migrated."""
60
+ from tyrtulia.adapters.storage.database import open_database
61
+ from tyrtulia.adapters.storage.migrations import pending_migrations
62
+ from tyrtulia.bootstrap import build_services
63
+
64
+ config = read_config()
65
+ database = open_database(config.storage.database)
66
+ with database.connection_context():
67
+ if pending_migrations(database):
68
+ raise fail("The database has pending migrations: run tyrtulia migrate")
69
+ try:
70
+ yield build_services(config, database)
71
+ except DomainError as error:
72
+ raise fail(error.detail) from error
73
+
74
+
75
+ def read_key(path: Path) -> str:
76
+ lines = [line for line in path.read_text().splitlines() if line.strip()]
77
+ if not lines:
78
+ raise fail(f"{path} is empty")
79
+ return lines[0]
80
+
81
+
82
+ @app.command()
83
+ def serve(
84
+ pidfile: Annotated[
85
+ Path | None,
86
+ typer.Option("--pidfile", help="Write the PID of the server here (for rc.d scripts)."),
87
+ ] = None,
88
+ ) -> None:
89
+ """Start the server."""
90
+ from tyrtulia.adapters.http.server import TyrtuliaServer
91
+ from tyrtulia.adapters.peercred import UnsupportedPlatform
92
+
93
+ read_config()
94
+ logging.basicConfig(
95
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
96
+ )
97
+ try:
98
+ server = TyrtuliaServer(state.config_path, pidfile)
99
+ except UnsupportedPlatform as error:
100
+ raise fail(f"{error}. Disable [listen].socket or use Linux or a BSD system") from error
101
+ server.run()
102
+
103
+
104
+ @app.command()
105
+ def migrate() -> None:
106
+ """Apply database migrations."""
107
+ from tyrtulia.adapters.storage.database import open_database
108
+ from tyrtulia.adapters.storage.migrations import migrate as apply_migrations
109
+
110
+ database = open_database(read_config().storage.database)
111
+ with database.connection_context():
112
+ applied = apply_migrations(database)
113
+ if applied:
114
+ for migration in applied:
115
+ typer.echo(f"Applied {migration.name}")
116
+ else:
117
+ typer.echo("The database is up to date")
118
+
119
+
120
+ @account_app.command("create")
121
+ def account_create(name: str, key: KeyOption) -> None:
122
+ """Create an SSH key account. Rejects Unix user names and reserved names."""
123
+ from tyrtulia.use_cases import admin
124
+
125
+ with open_services() as svc:
126
+ admin.create_account(svc, name, read_key(key))
127
+ typer.echo(f"Created the account {name}")
128
+
129
+
130
+ @account_app.command("rename")
131
+ def account_rename(old: str, new: str) -> None:
132
+ """Rename an SSH key account. Same rules as create."""
133
+ from tyrtulia.use_cases import admin
134
+
135
+ with open_services() as svc:
136
+ admin.rename_account(svc, old, new)
137
+ typer.echo(f"Renamed {old} to {new}")
138
+
139
+
140
+ @account_app.command("add-key")
141
+ def account_add_key(name: str, key: KeyOption) -> None:
142
+ """Add an SSH key to an account."""
143
+ from tyrtulia.use_cases import admin
144
+
145
+ with open_services() as svc:
146
+ admin.add_key(svc, name, read_key(key))
147
+ typer.echo(f"Added the key to {name}")
148
+
149
+
150
+ @identity_app.command("resolve")
151
+ def identity_resolve(
152
+ name: str,
153
+ rename_to: Annotated[
154
+ str | None,
155
+ typer.Option("--rename-to", help="New name for an SSH key account in conflict."),
156
+ ] = None,
157
+ ) -> None:
158
+ """Resolve a unix-identity-mismatch or a username-conflict."""
159
+ from tyrtulia.use_cases import admin
160
+
161
+ with open_services() as svc:
162
+ typer.echo(admin.resolve_identity(svc, name, rename_to))
163
+
164
+
165
+ @app.command("import-bbj")
166
+ def import_bbj(
167
+ database: Annotated[Path, typer.Argument(help="BBJ SQLite database.", exists=True)],
168
+ trust_unix_names: Annotated[
169
+ bool,
170
+ typer.Option(
171
+ "--trust-unix-names", help="Send the posts of BBJ users to the matching Unix accounts."
172
+ ),
173
+ ] = False,
174
+ ) -> None:
175
+ """Import a BBJ database."""
176
+ from tyrtulia.adapters.bbj_reader import read_bbj
177
+ from tyrtulia.use_cases.bbj_import import ImportConflicts
178
+ from tyrtulia.use_cases.bbj_import import import_bbj as run_import
179
+
180
+ data = read_bbj(database)
181
+ with open_services() as svc:
182
+ try:
183
+ summary = run_import(svc, data, trust_unix_names=trust_unix_names)
184
+ except ImportConflicts as error:
185
+ typer.echo("The import stopped; nothing was imported. Conflicts:", err=True)
186
+ for conflict in error.conflicts:
187
+ typer.echo(f" {conflict.username}: {conflict.reason}", err=True)
188
+ raise typer.Exit(1) from error
189
+ typer.echo(
190
+ f"Imported {summary.users} users, {summary.threads} threads and {summary.posts} posts"
191
+ )
192
+
193
+
194
+ def run() -> None:
195
+ app()
@@ -0,0 +1 @@
1
+ """HTTP adapter: Flask app, authentication, negotiation and representers."""
@@ -0,0 +1,111 @@
1
+ """Flask application factory."""
2
+
3
+ import logging
4
+
5
+ import peewee
6
+ from flask import Flask, Response, g
7
+ from werkzeug.exceptions import HTTPException
8
+
9
+ from tyrtulia.adapters.http import auth, negotiation
10
+ from tyrtulia.adapters.http.context import CONFIG_KEY, SERVICES_KEY
11
+ from tyrtulia.adapters.http.problems import CATALOG, generic_problem, problem
12
+ from tyrtulia.adapters.http.ratelimit import RateLimiter, SlidingWindowLimiter
13
+ from tyrtulia.adapters.http.routes import register_routes
14
+ from tyrtulia.adapters.http.routes.notifications import BROKER_KEY
15
+ from tyrtulia.adapters.http.sse import Broker
16
+ from tyrtulia.config import Config
17
+ from tyrtulia.domain.errors import DomainError
18
+ from tyrtulia.use_cases.common import EditConflict
19
+ from tyrtulia.use_cases.services import Services
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ RATE_LIMITER_KEY = "tyrtulia.rate_limiter"
24
+
25
+
26
+ def create_app(
27
+ config: Config,
28
+ svc: Services,
29
+ database: peewee.SqliteDatabase,
30
+ broker: Broker | None = None,
31
+ ) -> Flask:
32
+ app = Flask("tyrtulia")
33
+ app.json.sort_keys = False # type: ignore[attr-defined]
34
+ broker = broker or Broker(config.rate_limit.max_sse_per_user)
35
+ svc.publisher = broker
36
+ app.extensions[SERVICES_KEY] = svc
37
+ app.extensions[CONFIG_KEY] = config
38
+ app.extensions[BROKER_KEY] = broker
39
+
40
+ @app.before_request
41
+ def open_connection() -> None:
42
+ database.connect(reuse_if_open=True)
43
+
44
+ @app.teardown_request
45
+ def close_connection(_error: BaseException | None) -> None:
46
+ if not database.is_closed():
47
+ database.close()
48
+
49
+ limiter = RateLimiter(SlidingWindowLimiter(lambda: svc.clock.now().timestamp()))
50
+ app.extensions[RATE_LIMITER_KEY] = limiter
51
+
52
+ @app.before_request
53
+ def prepare() -> None:
54
+ """Identify, count against the rate limits, then negotiate.
55
+
56
+ Requests that fail authentication still count (by IP), so failed
57
+ attempts are limited and carry the rate limit headers too.
58
+ """
59
+ try:
60
+ auth.authenticate()
61
+ except DomainError:
62
+ g.actor = None
63
+ limiter.check()
64
+ raise
65
+ limiter.check()
66
+ negotiation.check_accept()
67
+
68
+ app.after_request(limiter.add_headers)
69
+
70
+ @app.errorhandler(DomainError)
71
+ def domain_error(error: DomainError) -> Response:
72
+ links = dict(error.links)
73
+ if isinstance(error, EditConflict):
74
+ links["latest-version"] = {"href": f"/posts/{error.post_id}", "method": "GET"}
75
+ if error.theirs is not None:
76
+ links["diff"] = {
77
+ "href": f"/posts/{error.post_id}/revisions/{error.current}"
78
+ f"?format=diff&from={error.theirs}",
79
+ "method": "GET",
80
+ "type": "text/x-diff",
81
+ }
82
+ if error.code not in CATALOG:
83
+ logger.error("Unknown error code %s", error.code)
84
+ return generic_problem(500, "Internal server error")
85
+ return problem(error.code, error.detail, links=links, headers=error.headers)
86
+
87
+ @app.errorhandler(HTTPException)
88
+ def http_error(error: HTTPException) -> Response:
89
+ status = error.code or 500
90
+ if status == 404:
91
+ return problem("not-found", "The resource does not exist")
92
+ if status == 415:
93
+ return problem("unsupported-media-type", "Unsupported media type")
94
+ response = generic_problem(status, error.name, error.description)
95
+ if status == 405 and hasattr(error, "valid_methods") and error.valid_methods:
96
+ response.headers["Allow"] = ", ".join(error.valid_methods)
97
+ return response
98
+
99
+ @app.errorhandler(Exception)
100
+ def unexpected_error(error: Exception) -> Response:
101
+ logger.exception("Unexpected error", exc_info=error)
102
+ return generic_problem(500, "Internal server error")
103
+
104
+ @app.after_request
105
+ def empty_responses(response: Response) -> Response:
106
+ if response.status_code in (202, 204):
107
+ response.headers.pop("Content-Type", None)
108
+ return response
109
+
110
+ register_routes(app)
111
+ return app
@@ -0,0 +1,64 @@
1
+ """Atom documents (RFC 4287)."""
2
+
3
+ from datetime import datetime
4
+ from xml.etree import ElementTree
5
+
6
+ from tyrtulia.adapters.http.representers import iso
7
+
8
+ ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"
9
+
10
+ ElementTree.register_namespace("", ATOM_NAMESPACE)
11
+
12
+
13
+ def _tag(name: str) -> str:
14
+ return f"{{{ATOM_NAMESPACE}}}{name}"
15
+
16
+
17
+ def _text(parent: ElementTree.Element, name: str, text: str, **attributes: str) -> None:
18
+ element = ElementTree.SubElement(parent, _tag(name), attributes)
19
+ element.text = text
20
+
21
+
22
+ def _link(parent: ElementTree.Element, rel: str, href: str, media_type: str) -> None:
23
+ ElementTree.SubElement(parent, _tag("link"), {"rel": rel, "href": href, "type": media_type})
24
+
25
+
26
+ class Feed:
27
+ def __init__(self, feed_id: str, title: str, self_href: str, alternate_href: str) -> None:
28
+ self.root = ElementTree.Element(_tag("feed"))
29
+ _text(self.root, "id", feed_id)
30
+ _text(self.root, "title", title)
31
+ self._updated = ElementTree.SubElement(self.root, _tag("updated"))
32
+ _link(self.root, "self", self_href, "application/atom+xml")
33
+ _link(self.root, "alternate", alternate_href, "application/vnd.tyrtulia.v1+json")
34
+ self.updated: datetime | None = None
35
+
36
+ def add_entry(
37
+ self,
38
+ entry_id: str,
39
+ title: str,
40
+ author: str,
41
+ href: str,
42
+ published: datetime,
43
+ updated: datetime,
44
+ html: str | None,
45
+ ) -> None:
46
+ entry = ElementTree.SubElement(self.root, _tag("entry"))
47
+ _text(entry, "id", f"urn:uuid:{entry_id}")
48
+ _text(entry, "title", title)
49
+ author_element = ElementTree.SubElement(entry, _tag("author"))
50
+ _text(author_element, "name", author)
51
+ _link(entry, "alternate", href, "application/vnd.tyrtulia.v1+json")
52
+ _text(entry, "published", iso(published))
53
+ _text(entry, "updated", iso(updated))
54
+ if html is not None:
55
+ _text(entry, "content", html, type="html")
56
+ if self.updated is None or updated > self.updated:
57
+ self.updated = updated
58
+
59
+ def render(self, fallback_updated: datetime) -> bytes:
60
+ self._updated.text = iso(self.updated or fallback_updated)
61
+ return (
62
+ b'<?xml version="1.0" encoding="utf-8"?>\n'
63
+ + ElementTree.tostring(self.root, encoding="unicode").encode()
64
+ )
@@ -0,0 +1,38 @@
1
+ """Who is asking: the socket peer (``SO_PEERCRED``) or a session token."""
2
+
3
+ from flask import g, request
4
+
5
+ from tyrtulia.adapters.http.context import services
6
+ from tyrtulia.domain.errors import DomainError, unauthenticated
7
+ from tyrtulia.domain.roles import Role
8
+ from tyrtulia.use_cases.accounts import identify_unix
9
+ from tyrtulia.use_cases.sessions import authenticate_token
10
+
11
+ PEER_UID_KEY = "tyrtulia.peer_uid"
12
+ READ_METHODS = frozenset({"GET", "HEAD"})
13
+ LOGOUT_ENDPOINT = "auth.logout"
14
+
15
+
16
+ def authenticate() -> None:
17
+ g.actor = None
18
+ uid = request.environ.get(PEER_UID_KEY)
19
+ if uid is not None:
20
+ g.actor = identify_unix(services(), int(uid))
21
+ elif "Authorization" in request.headers:
22
+ scheme, _, token = request.headers["Authorization"].partition(" ")
23
+ if scheme.lower() != "bearer" or not token.strip():
24
+ raise unauthenticated("Use Authorization: Bearer with a session token")
25
+ g.actor = authenticate_token(services(), token.strip())
26
+ actor = g.actor
27
+ if actor is None:
28
+ return
29
+ if (
30
+ actor.role is Role.READONLY
31
+ and request.method not in READ_METHODS
32
+ and request.endpoint != LOGOUT_ENDPOINT
33
+ ):
34
+ raise DomainError("readonly", "Read-only accounts can only read")
35
+
36
+
37
+ def via_socket() -> bool:
38
+ return request.environ.get(PEER_UID_KEY) is not None
@@ -0,0 +1,43 @@
1
+ """Decoding request bodies and query parameters."""
2
+
3
+ import msgspec
4
+ from flask import request
5
+
6
+ from tyrtulia.adapters.http.negotiation import JSON_TYPES
7
+ from tyrtulia.domain.errors import DomainError, validation_failed
8
+
9
+ MERGE_PATCH_TYPE = "application/merge-patch+json"
10
+
11
+
12
+ def read_body[T](struct: type[T]) -> T:
13
+ """Decode a JSON body (``POST``, ``PUT``) or a merge patch (``PATCH``)."""
14
+ media_type = request.mimetype
15
+ if request.method == "PATCH":
16
+ if media_type != MERGE_PATCH_TYPE:
17
+ raise DomainError("unsupported-media-type", f"PATCH requires {MERGE_PATCH_TYPE}")
18
+ elif media_type not in JSON_TYPES:
19
+ raise DomainError("unsupported-media-type", "The body must be application/json")
20
+ try:
21
+ return msgspec.json.decode(request.get_data(), type=struct)
22
+ except msgspec.ValidationError as error:
23
+ raise validation_failed(str(error)) from error
24
+ except msgspec.DecodeError as error:
25
+ raise DomainError("unsupported-media-type", "The body is not valid JSON") from error
26
+
27
+
28
+ def query_int(name: str, *, minimum: int, maximum: int | None = None) -> int | None:
29
+ raw = request.args.get(name)
30
+ if raw is None:
31
+ return None
32
+ if not raw.isdigit():
33
+ raise validation_failed(f"{name} must be an integer")
34
+ value = int(raw)
35
+ if value < minimum or (maximum is not None and value > maximum):
36
+ limit = f"between {minimum} and {maximum}" if maximum is not None else f">= {minimum}"
37
+ raise validation_failed(f"{name} must be {limit}")
38
+ return value
39
+
40
+
41
+ def given[T](value: T | msgspec.UnsetType) -> T | None:
42
+ """The value of an optional field, or ``None`` when it was not sent."""
43
+ return None if isinstance(value, msgspec.UnsetType) else value
@@ -0,0 +1,45 @@
1
+ """Per-request access to the services and the acting user."""
2
+
3
+ from typing import Any
4
+
5
+ import msgspec
6
+ from flask import Response, current_app, g
7
+
8
+ from tyrtulia.adapters.http.negotiation import API_TYPE
9
+ from tyrtulia.config import Config
10
+ from tyrtulia.domain.entities import Actor
11
+ from tyrtulia.use_cases.services import Services
12
+
13
+ SERVICES_KEY = "tyrtulia.services"
14
+ CONFIG_KEY = "tyrtulia.config"
15
+
16
+
17
+ def services() -> Services:
18
+ svc: Services = current_app.extensions[SERVICES_KEY]
19
+ return svc
20
+
21
+
22
+ def config() -> Config:
23
+ cfg: Config = current_app.extensions[CONFIG_KEY]
24
+ return cfg
25
+
26
+
27
+ def actor() -> Actor | None:
28
+ current: Actor | None = g.get("actor")
29
+ return current
30
+
31
+
32
+ def hal(
33
+ body: dict[str, Any],
34
+ status: int = 200,
35
+ *,
36
+ headers: dict[str, str] | None = None,
37
+ ) -> Response:
38
+ response = Response(msgspec.json.encode(body), status=status, mimetype=API_TYPE)
39
+ for name, value in (headers or {}).items():
40
+ response.headers[name] = value
41
+ return response
42
+
43
+
44
+ def no_content() -> Response:
45
+ return Response(status=204)
@@ -0,0 +1,58 @@
1
+ """Content negotiation for the versioned media type."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from flask import current_app, request
7
+
8
+ from tyrtulia.domain.errors import DomainError
9
+
10
+ API_TYPE = "application/vnd.tyrtulia.v1+json"
11
+ JSON_TYPES = frozenset({API_TYPE, "application/json"})
12
+ DEFAULT_ACCEPTED = frozenset({API_TYPE, "application/json", "application/*", "*/*"})
13
+
14
+ type View = Callable[..., Any]
15
+
16
+
17
+ def produces(*media_types: str) -> Callable[[View], View]:
18
+ """Declare the extra media types a view can answer with."""
19
+
20
+ def decorator(view: View) -> View:
21
+ view.produces = frozenset(media_types) # type: ignore[attr-defined]
22
+ return view
23
+
24
+ return decorator
25
+
26
+
27
+ def _accepted_types(header: str) -> list[str]:
28
+ accepted = []
29
+ for entry in header.split(","):
30
+ parts = [part.strip() for part in entry.split(";")]
31
+ media_type = parts[0].lower()
32
+ if not media_type:
33
+ continue
34
+ quality = 1.0
35
+ for parameter in parts[1:]:
36
+ name, _, value = parameter.partition("=")
37
+ if name.strip().lower() == "q":
38
+ try:
39
+ quality = float(value)
40
+ except ValueError:
41
+ quality = 0.0
42
+ if quality > 0:
43
+ accepted.append(media_type)
44
+ return accepted
45
+
46
+
47
+ def check_accept() -> None:
48
+ header = request.headers.get("Accept")
49
+ if header is None or not header.strip():
50
+ return
51
+ allowed = set(DEFAULT_ACCEPTED)
52
+ view = current_app.view_functions.get(request.endpoint or "")
53
+ allowed |= getattr(view, "produces", frozenset())
54
+ if not any(media_type in allowed for media_type in _accepted_types(header)):
55
+ raise DomainError(
56
+ "not-acceptable",
57
+ f"Send Accept with {API_TYPE}, application/json or */*",
58
+ )