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
tyrtulia/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tyrtulia: a headless forum for small multi-user servers."""
|
tyrtulia/__main__.py
ADDED
|
@@ -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
|
+
)
|