librus-python-api 1.0.0rc1__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.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_codec.py +310 -0
- librus_python_api/_storage.py +367 -0
- librus_python_api/announcements.py +159 -0
- librus_python_api/attachment_routes.py +114 -0
- librus_python_api/attachments.py +297 -0
- librus_python_api/attendance.py +182 -0
- librus_python_api/attendance_frequency.py +112 -0
- librus_python_api/budget.py +79 -0
- librus_python_api/checkpoint.py +61 -0
- librus_python_api/completed_lessons.py +216 -0
- librus_python_api/config.py +1410 -0
- librus_python_api/detail_fields.py +50 -0
- librus_python_api/diagnostics.py +25 -0
- librus_python_api/exceptions.py +172 -0
- librus_python_api/files.py +156 -0
- librus_python_api/grade_parsers.py +169 -0
- librus_python_api/grade_records.py +454 -0
- librus_python_api/homework_range.py +41 -0
- librus_python_api/lifecycle.py +24 -0
- librus_python_api/markup.py +147 -0
- librus_python_api/message_content.py +230 -0
- librus_python_api/messages.py +288 -0
- librus_python_api/models.py +1160 -0
- librus_python_api/modern_body.py +75 -0
- librus_python_api/modern_mailbox.py +459 -0
- librus_python_api/modern_messages.py +276 -0
- librus_python_api/notification_models.py +67 -0
- librus_python_api/notification_persistence.py +1044 -0
- librus_python_api/notification_workflow.py +303 -0
- librus_python_api/notifications.py +216 -0
- librus_python_api/parsers.py +232 -0
- librus_python_api/parsing.py +49 -0
- librus_python_api/persistence.py +405 -0
- librus_python_api/py.typed +0 -0
- librus_python_api/recipients.py +271 -0
- librus_python_api/scheduler.py +287 -0
- librus_python_api/school_reads.py +400 -0
- librus_python_api/sending.py +125 -0
- librus_python_api/service.py +2285 -0
- librus_python_api/timetable.py +261 -0
- librus_python_api/transport.py +956 -0
- librus_python_api-1.0.0rc1.dist-info/METADATA +254 -0
- librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
- librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
- librus_python_api-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Private bounded SQLite ownership shared by explicit optional stores."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import sqlite3
|
|
10
|
+
import stat
|
|
11
|
+
from collections.abc import Callable, Coroutine, Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Self
|
|
15
|
+
|
|
16
|
+
from pydantic import Field
|
|
17
|
+
|
|
18
|
+
from librus_python_api.config import _ValidatedConfig
|
|
19
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
20
|
+
from librus_python_api.lifecycle import join_owned
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _StorageLimits(_ValidatedConfig):
|
|
24
|
+
operations: int = Field(default=8, ge=1, le=64)
|
|
25
|
+
busy_timeout_seconds: float = Field(default=0.1, gt=0, le=5, allow_inf_nan=False)
|
|
26
|
+
# Final saves follow upstream side effects (a dispatched send or a consumed
|
|
27
|
+
# read-once page), so they wait longer for other contexts' write locks.
|
|
28
|
+
final_busy_timeout_seconds: float = Field(
|
|
29
|
+
default=5, gt=0, le=60, allow_inf_nan=False
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Worker threads inherit the calling task's context through asyncio.to_thread.
|
|
34
|
+
_busy_timeout_seconds: contextvars.ContextVar[float | None] = contextvars.ContextVar(
|
|
35
|
+
"_busy_timeout_seconds", default=None
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_STORE_META_SCHEMA = """CREATE TABLE store_metadata (
|
|
39
|
+
name TEXT PRIMARY KEY CHECK(name='context_salt'),
|
|
40
|
+
value BLOB NOT NULL CHECK(length(value)=32)
|
|
41
|
+
)"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _SQLiteStore:
|
|
45
|
+
_filename = "state.sqlite3"
|
|
46
|
+
_database_bytes = 8 * 1024 * 1024
|
|
47
|
+
_schema: tuple[str, ...] = ()
|
|
48
|
+
|
|
49
|
+
def __init__(self, directory: Path, *, limits: _StorageLimits) -> None:
|
|
50
|
+
if not isinstance(directory, Path) or not directory.is_absolute():
|
|
51
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
52
|
+
self._directory = directory
|
|
53
|
+
self._path = directory / self._filename
|
|
54
|
+
self._storage_limits = limits
|
|
55
|
+
self._opened = self._opening = self._closing = self._closed = False
|
|
56
|
+
self._operations = 0
|
|
57
|
+
self._lock = asyncio.Lock()
|
|
58
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
59
|
+
self._workflows: set[asyncio.Task[Any]] = set()
|
|
60
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
61
|
+
self._file_identity: tuple[int, int] | None = None
|
|
62
|
+
self._context_salt: bytes | None = None
|
|
63
|
+
|
|
64
|
+
def context_identifier(self, context: str) -> str:
|
|
65
|
+
"""Store-local context pseudonym; requires an explicitly opened store."""
|
|
66
|
+
if not self._opened or self._closed:
|
|
67
|
+
raise LibrusError(ErrorKind.CLOSED)
|
|
68
|
+
return self._context_key(context)
|
|
69
|
+
|
|
70
|
+
def _context_key(self, context: str) -> str:
|
|
71
|
+
if type(context) is not str or len(context) != 64:
|
|
72
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
73
|
+
try:
|
|
74
|
+
decoded = bytes.fromhex(context)
|
|
75
|
+
except ValueError:
|
|
76
|
+
raise LibrusError(ErrorKind.INVALID_INPUT) from None
|
|
77
|
+
if len(decoded) != 32 or context != decoded.hex():
|
|
78
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
79
|
+
assert self._context_salt is not None
|
|
80
|
+
return hmac.new(self._context_salt, decoded, hashlib.sha256).hexdigest()
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> Self:
|
|
83
|
+
await self.open()
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *args: object) -> None:
|
|
87
|
+
await self.aclose()
|
|
88
|
+
|
|
89
|
+
async def open(self) -> None:
|
|
90
|
+
if os.name != "posix":
|
|
91
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
92
|
+
if self._opened or self._opening or self._closing or self._closed:
|
|
93
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
94
|
+
self._opening = True
|
|
95
|
+
try:
|
|
96
|
+
await self._io(self._initialise, opening=True)
|
|
97
|
+
self._opened = True
|
|
98
|
+
finally:
|
|
99
|
+
self._opening = False
|
|
100
|
+
|
|
101
|
+
async def aclose(self) -> None:
|
|
102
|
+
if (
|
|
103
|
+
asyncio.current_task() in self._tasks
|
|
104
|
+
or asyncio.current_task() in self._workflows
|
|
105
|
+
):
|
|
106
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
107
|
+
if self._close_task is None:
|
|
108
|
+
self._closing = True
|
|
109
|
+
self._close_task = asyncio.create_task(self._close())
|
|
110
|
+
interrupted = await join_owned(self._close_task)
|
|
111
|
+
self._close_task.result()
|
|
112
|
+
if interrupted:
|
|
113
|
+
raise asyncio.CancelledError
|
|
114
|
+
|
|
115
|
+
async def _close(self) -> None:
|
|
116
|
+
# Join workflow-owned final saves before cancelling ordinary workers.
|
|
117
|
+
for task in tuple(self._workflows):
|
|
118
|
+
task.cancel()
|
|
119
|
+
for task in tuple(self._workflows):
|
|
120
|
+
await join_owned(task)
|
|
121
|
+
tasks = tuple(self._tasks)
|
|
122
|
+
for task in tasks:
|
|
123
|
+
task.cancel()
|
|
124
|
+
for task in tasks:
|
|
125
|
+
await join_owned(task)
|
|
126
|
+
self._closed = True
|
|
127
|
+
self._opened = False
|
|
128
|
+
|
|
129
|
+
async def _owned[T](self, operation: Callable[[], Coroutine[Any, Any, T]]) -> T:
|
|
130
|
+
if not self._opened or self._closing or self._closed:
|
|
131
|
+
raise LibrusError(ErrorKind.CLOSED)
|
|
132
|
+
if len(self._workflows) >= self._storage_limits.operations:
|
|
133
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
134
|
+
task = asyncio.create_task(operation())
|
|
135
|
+
self._workflows.add(task)
|
|
136
|
+
cancelled = False
|
|
137
|
+
try:
|
|
138
|
+
await asyncio.wait((task,))
|
|
139
|
+
return task.result()
|
|
140
|
+
except asyncio.CancelledError:
|
|
141
|
+
cancelled = True
|
|
142
|
+
task.cancel()
|
|
143
|
+
raise
|
|
144
|
+
finally:
|
|
145
|
+
interrupted = await join_owned(task)
|
|
146
|
+
self._workflows.discard(task)
|
|
147
|
+
if cancelled or interrupted:
|
|
148
|
+
raise asyncio.CancelledError
|
|
149
|
+
|
|
150
|
+
async def _io[T](
|
|
151
|
+
self,
|
|
152
|
+
operation: Callable[[], T],
|
|
153
|
+
*,
|
|
154
|
+
opening: bool = False,
|
|
155
|
+
finishing: bool = False,
|
|
156
|
+
) -> T:
|
|
157
|
+
if (
|
|
158
|
+
self._closed
|
|
159
|
+
or (not self._opened and not opening)
|
|
160
|
+
or (self._closing and not finishing)
|
|
161
|
+
):
|
|
162
|
+
raise LibrusError(ErrorKind.CLOSED)
|
|
163
|
+
if self._operations >= self._storage_limits.operations:
|
|
164
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
165
|
+
self._operations += 1
|
|
166
|
+
task = asyncio.create_task(
|
|
167
|
+
self._worker(
|
|
168
|
+
operation,
|
|
169
|
+
self._storage_limits.final_busy_timeout_seconds
|
|
170
|
+
if finishing
|
|
171
|
+
else self._storage_limits.busy_timeout_seconds,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
self._tasks.add(task)
|
|
175
|
+
cancelled = False
|
|
176
|
+
try:
|
|
177
|
+
await asyncio.wait((task,))
|
|
178
|
+
return task.result()
|
|
179
|
+
except asyncio.CancelledError:
|
|
180
|
+
cancelled = True
|
|
181
|
+
# Final saves remain owned even while waiting for another worker.
|
|
182
|
+
if not finishing:
|
|
183
|
+
task.cancel()
|
|
184
|
+
raise
|
|
185
|
+
finally:
|
|
186
|
+
interrupted = await join_owned(task)
|
|
187
|
+
self._tasks.discard(task)
|
|
188
|
+
self._operations -= 1
|
|
189
|
+
if cancelled or interrupted:
|
|
190
|
+
raise asyncio.CancelledError
|
|
191
|
+
|
|
192
|
+
async def _worker[T](self, operation: Callable[[], T], busy_seconds: float) -> T:
|
|
193
|
+
async with self._lock:
|
|
194
|
+
_busy_timeout_seconds.set(busy_seconds)
|
|
195
|
+
task = asyncio.create_task(asyncio.to_thread(operation))
|
|
196
|
+
interrupted = await join_owned(task)
|
|
197
|
+
if interrupted:
|
|
198
|
+
raise asyncio.CancelledError
|
|
199
|
+
try:
|
|
200
|
+
return task.result()
|
|
201
|
+
except LibrusError:
|
|
202
|
+
raise
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
206
|
+
|
|
207
|
+
def _check_directory(self) -> None:
|
|
208
|
+
# A private immediate parent is the trust boundary. Malicious same-user
|
|
209
|
+
# filesystem writers and unsuitable network filesystems are not isolated.
|
|
210
|
+
if self._directory.is_symlink():
|
|
211
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
212
|
+
self._directory.mkdir(mode=0o700, exist_ok=True)
|
|
213
|
+
status = self._directory.stat()
|
|
214
|
+
if not stat.S_ISDIR(status.st_mode):
|
|
215
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
216
|
+
if os.name == "posix" and (
|
|
217
|
+
status.st_uid != os.geteuid() or stat.S_IMODE(status.st_mode) & 0o077
|
|
218
|
+
):
|
|
219
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
220
|
+
|
|
221
|
+
def _check_file(self, *, create: bool = False) -> None:
|
|
222
|
+
if self._path.is_symlink():
|
|
223
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
224
|
+
flags = os.O_RDWR | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
225
|
+
if create:
|
|
226
|
+
flags |= os.O_CREAT
|
|
227
|
+
descriptor = os.open(self._path, flags, 0o600)
|
|
228
|
+
try:
|
|
229
|
+
status = os.fstat(descriptor)
|
|
230
|
+
if (
|
|
231
|
+
not stat.S_ISREG(status.st_mode)
|
|
232
|
+
or status.st_nlink != 1
|
|
233
|
+
or status.st_size > self._database_bytes
|
|
234
|
+
):
|
|
235
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
236
|
+
if os.name == "posix" and (
|
|
237
|
+
status.st_uid != os.geteuid() or stat.S_IMODE(status.st_mode) & 0o077
|
|
238
|
+
):
|
|
239
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
240
|
+
identity = (status.st_dev, status.st_ino)
|
|
241
|
+
if self._file_identity is not None and self._file_identity != identity:
|
|
242
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
243
|
+
self._file_identity = identity
|
|
244
|
+
finally:
|
|
245
|
+
os.close(descriptor)
|
|
246
|
+
|
|
247
|
+
def _check_sidecars(self) -> None:
|
|
248
|
+
for suffix in ("-journal", "-wal", "-shm"):
|
|
249
|
+
path = self._path.with_name(self._path.name + suffix)
|
|
250
|
+
try:
|
|
251
|
+
status = path.lstat()
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
continue
|
|
254
|
+
if (
|
|
255
|
+
not stat.S_ISREG(status.st_mode)
|
|
256
|
+
or status.st_nlink != 1
|
|
257
|
+
or status.st_size > self._database_bytes + 1024 * 1024
|
|
258
|
+
or (
|
|
259
|
+
os.name == "posix"
|
|
260
|
+
and (
|
|
261
|
+
status.st_uid != os.geteuid()
|
|
262
|
+
or stat.S_IMODE(status.st_mode) & 0o077
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
):
|
|
266
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
267
|
+
|
|
268
|
+
@contextmanager
|
|
269
|
+
def _connection(self, *, initialise: bool = False) -> Iterator[sqlite3.Connection]:
|
|
270
|
+
connection = None
|
|
271
|
+
kind: ErrorKind | None = None
|
|
272
|
+
try:
|
|
273
|
+
self._check_directory()
|
|
274
|
+
self._check_file(create=initialise)
|
|
275
|
+
self._check_sidecars()
|
|
276
|
+
busy_seconds = _busy_timeout_seconds.get()
|
|
277
|
+
connection = sqlite3.connect(
|
|
278
|
+
self._path,
|
|
279
|
+
timeout=self._storage_limits.busy_timeout_seconds
|
|
280
|
+
if busy_seconds is None
|
|
281
|
+
else busy_seconds,
|
|
282
|
+
)
|
|
283
|
+
connection.execute("PRAGMA trusted_schema=OFF")
|
|
284
|
+
connection.execute("PRAGMA foreign_keys=ON")
|
|
285
|
+
connection.execute("PRAGMA synchronous=FULL")
|
|
286
|
+
connection.execute(f"PRAGMA max_page_count={self._database_bytes // 4096}")
|
|
287
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
288
|
+
if not initialise:
|
|
289
|
+
self._validate_schema(connection)
|
|
290
|
+
yield connection
|
|
291
|
+
connection.commit()
|
|
292
|
+
except LibrusError:
|
|
293
|
+
raise
|
|
294
|
+
except sqlite3.Error as error:
|
|
295
|
+
kind = (
|
|
296
|
+
ErrorKind.LIMIT
|
|
297
|
+
if getattr(error, "sqlite_errorcode", None)
|
|
298
|
+
in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED)
|
|
299
|
+
else ErrorKind.STORAGE
|
|
300
|
+
)
|
|
301
|
+
except OSError:
|
|
302
|
+
kind = ErrorKind.STORAGE
|
|
303
|
+
finally:
|
|
304
|
+
if connection is not None:
|
|
305
|
+
connection.close()
|
|
306
|
+
if kind is not None:
|
|
307
|
+
raise LibrusError(kind)
|
|
308
|
+
|
|
309
|
+
def _initialise(self) -> None:
|
|
310
|
+
with self._connection(initialise=True) as connection:
|
|
311
|
+
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
312
|
+
if version == 0:
|
|
313
|
+
if connection.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone():
|
|
314
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
315
|
+
for sql in (_STORE_META_SCHEMA, *self._schema):
|
|
316
|
+
connection.execute(sql)
|
|
317
|
+
connection.execute(
|
|
318
|
+
"INSERT INTO store_metadata VALUES (?,?)",
|
|
319
|
+
("context_salt", secrets.token_bytes(32)),
|
|
320
|
+
)
|
|
321
|
+
connection.execute("PRAGMA user_version=3")
|
|
322
|
+
self._validate_schema(connection)
|
|
323
|
+
self._validate_contents(connection)
|
|
324
|
+
if os.name == "posix":
|
|
325
|
+
descriptor = os.open(
|
|
326
|
+
self._directory,
|
|
327
|
+
os.O_RDONLY
|
|
328
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
329
|
+
| getattr(os, "O_NOFOLLOW", 0),
|
|
330
|
+
)
|
|
331
|
+
try:
|
|
332
|
+
os.fsync(descriptor)
|
|
333
|
+
finally:
|
|
334
|
+
os.close(descriptor)
|
|
335
|
+
|
|
336
|
+
def _validate_schema(self, connection: sqlite3.Connection) -> None:
|
|
337
|
+
expected = []
|
|
338
|
+
for sql in (_STORE_META_SCHEMA, *self._schema):
|
|
339
|
+
name = sql.split()[2]
|
|
340
|
+
expected.extend(
|
|
341
|
+
[("table", name, sql), ("index", f"sqlite_autoindex_{name}_1", None)]
|
|
342
|
+
)
|
|
343
|
+
actual = connection.execute(
|
|
344
|
+
"SELECT type, name, sql FROM sqlite_master ORDER BY name LIMIT 32"
|
|
345
|
+
).fetchall()
|
|
346
|
+
if (
|
|
347
|
+
connection.execute("PRAGMA user_version").fetchone()[0] != 3
|
|
348
|
+
or connection.execute("PRAGMA page_size").fetchone()[0] != 4096
|
|
349
|
+
or connection.execute("PRAGMA journal_mode").fetchone()[0] != "delete"
|
|
350
|
+
or actual != sorted(expected, key=lambda row: row[1])
|
|
351
|
+
):
|
|
352
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
353
|
+
rows = connection.execute(
|
|
354
|
+
"SELECT name,value FROM store_metadata LIMIT 2"
|
|
355
|
+
).fetchall()
|
|
356
|
+
if (
|
|
357
|
+
len(rows) != 1
|
|
358
|
+
or rows[0][0] != "context_salt"
|
|
359
|
+
or type(rows[0][1]) is not bytes
|
|
360
|
+
or len(rows[0][1]) != 32
|
|
361
|
+
or (self._context_salt is not None and self._context_salt != rows[0][1])
|
|
362
|
+
):
|
|
363
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
364
|
+
self._context_salt = rows[0][1]
|
|
365
|
+
|
|
366
|
+
def _validate_contents(self, connection: sqlite3.Connection) -> None:
|
|
367
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Bounded semantic school announcements, independently authored from requirements."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
from lxml import html
|
|
8
|
+
|
|
9
|
+
from librus_python_api import markup
|
|
10
|
+
from librus_python_api.config import (
|
|
11
|
+
ANNOUNCEMENT_EMPTY_CLASSES,
|
|
12
|
+
ANNOUNCEMENT_EMPTY_MARKERS,
|
|
13
|
+
ANNOUNCEMENT_LABELS,
|
|
14
|
+
ANNOUNCEMENT_MAX_CONTENT_LENGTH,
|
|
15
|
+
ANNOUNCEMENT_MAX_FIELD_LENGTH,
|
|
16
|
+
ANNOUNCEMENT_MAX_ITEMS,
|
|
17
|
+
ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH,
|
|
18
|
+
ANNOUNCEMENT_TABLE_CLASSES,
|
|
19
|
+
)
|
|
20
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
21
|
+
from librus_python_api.models import Announcement
|
|
22
|
+
from librus_python_api.parsers import parse_page
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _label(cell: html.HtmlElement) -> str | None:
|
|
26
|
+
return ANNOUNCEMENT_LABELS.get(
|
|
27
|
+
markup.text(cell, ANNOUNCEMENT_MAX_FIELD_LENGTH).rstrip(":").casefold()
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _empty(document: html.HtmlElement) -> bool:
|
|
32
|
+
markers: list[html.HtmlElement] = []
|
|
33
|
+
for node in document.iter("div"):
|
|
34
|
+
if ANNOUNCEMENT_EMPTY_CLASSES <= set(node.get("class", "").split()):
|
|
35
|
+
for child in node:
|
|
36
|
+
if child.tag == "div":
|
|
37
|
+
markers.extend(p for p in child if p.tag == "p")
|
|
38
|
+
if len(markers) > 1:
|
|
39
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
40
|
+
return bool(markers) and (
|
|
41
|
+
markup.text(markers[0], ANNOUNCEMENT_MAX_FIELD_LENGTH).rstrip(".").casefold()
|
|
42
|
+
in ANNOUNCEMENT_EMPTY_MARKERS
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tables(document: html.HtmlElement) -> list[html.HtmlElement]:
|
|
47
|
+
tables = []
|
|
48
|
+
for table in document.iter("table"):
|
|
49
|
+
classes = set(table.get("class", "").split())
|
|
50
|
+
labels = {
|
|
51
|
+
_label(cell)
|
|
52
|
+
for row in markup.rows(table)
|
|
53
|
+
for cell in markup.cells(row)
|
|
54
|
+
if cell.tag == "th"
|
|
55
|
+
} - {None}
|
|
56
|
+
known = ANNOUNCEMENT_TABLE_CLASSES <= classes
|
|
57
|
+
if not known and len(labels) >= 2:
|
|
58
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
59
|
+
if known:
|
|
60
|
+
if next(table.iterancestors("table"), None) is not None:
|
|
61
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
62
|
+
tables.append(table)
|
|
63
|
+
if len(tables) > ANNOUNCEMENT_MAX_ITEMS:
|
|
64
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
65
|
+
return tables
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fields(table: html.HtmlElement) -> dict[str, str]:
|
|
69
|
+
if len(list(table.iter("table"))) != 1:
|
|
70
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
71
|
+
fields: dict[str, str] = {}
|
|
72
|
+
for row in markup.rows(table):
|
|
73
|
+
key: str | None
|
|
74
|
+
cells = markup.cells(row)
|
|
75
|
+
if next(row.iterancestors("thead"), None) is not None:
|
|
76
|
+
if len(cells) != 1 or cells[0].tag != "td" or "title" in fields:
|
|
77
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
78
|
+
key, cell = "title", cells[0]
|
|
79
|
+
if cell.get("colspan", "1") not in {"1", "2"}:
|
|
80
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
81
|
+
elif {"line0", "line1"}.intersection(row.get("class", "").split()):
|
|
82
|
+
if len(cells) != 2 or [cell.tag for cell in cells] != ["th", "td"]:
|
|
83
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
84
|
+
key, cell = _label(cells[0]), cells[1]
|
|
85
|
+
if key is None:
|
|
86
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
87
|
+
if any(
|
|
88
|
+
c.get("colspan", "1") != "1" or c.get("rowspan", "1") != "1"
|
|
89
|
+
for c in cells
|
|
90
|
+
):
|
|
91
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
92
|
+
elif markup.text(row, ANNOUNCEMENT_MAX_FIELD_LENGTH):
|
|
93
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
94
|
+
else:
|
|
95
|
+
continue
|
|
96
|
+
if key in fields or cell.get("rowspan", "1") != "1":
|
|
97
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
98
|
+
limit = (
|
|
99
|
+
ANNOUNCEMENT_MAX_CONTENT_LENGTH
|
|
100
|
+
if key == "content"
|
|
101
|
+
else ANNOUNCEMENT_MAX_FIELD_LENGTH
|
|
102
|
+
)
|
|
103
|
+
fields[key] = markup.text(cell, limit, multiline=key == "content")
|
|
104
|
+
if fields.keys() != {"title", "author", "date_text", "content"}:
|
|
105
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
106
|
+
if any(not fields[key] for key in ("title", "author", "date_text")):
|
|
107
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
108
|
+
return fields
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _day(value: str) -> date:
|
|
112
|
+
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
|
|
113
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
114
|
+
try:
|
|
115
|
+
return date.fromisoformat(value)
|
|
116
|
+
except ValueError:
|
|
117
|
+
pass
|
|
118
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _reference(account: str, fields: dict[str, str]) -> str:
|
|
122
|
+
digest = hashlib.sha256(b"librus-announcements-v1\x00")
|
|
123
|
+
for value in (
|
|
124
|
+
account,
|
|
125
|
+
*(fields[key] for key in ("title", "author", "date_text", "content")),
|
|
126
|
+
):
|
|
127
|
+
encoded = value.encode("utf-8")
|
|
128
|
+
digest.update(len(encoded).to_bytes(4, "big"))
|
|
129
|
+
digest.update(encoded)
|
|
130
|
+
return "content-sha256:" + digest.hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_announcements(body: bytes, account: str) -> tuple[Announcement, ...]:
|
|
134
|
+
if not isinstance(account, str) or not account or len(account) > 80:
|
|
135
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
136
|
+
document = parse_page(body)
|
|
137
|
+
tables, empty = _tables(document), _empty(document)
|
|
138
|
+
if empty and tables:
|
|
139
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
140
|
+
if not tables and not empty:
|
|
141
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
142
|
+
items = []
|
|
143
|
+
total = 0
|
|
144
|
+
for table in tables:
|
|
145
|
+
fields = _fields(table)
|
|
146
|
+
total += sum(len(value) for value in fields.values())
|
|
147
|
+
if total > ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH:
|
|
148
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
149
|
+
items.append(
|
|
150
|
+
Announcement(
|
|
151
|
+
reference=_reference(account, fields),
|
|
152
|
+
published_on=_day(fields["date_text"]),
|
|
153
|
+
title=fields["title"],
|
|
154
|
+
author=fields["author"],
|
|
155
|
+
date_text=fields["date_text"],
|
|
156
|
+
content=fields["content"],
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return tuple(items)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Fail-closed reference and signed-destination validation, never network I/O."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
from librus_python_api.config import (
|
|
7
|
+
ATTACHMENT_KEY_PATTERN,
|
|
8
|
+
ATTACHMENT_MAX_BYTES,
|
|
9
|
+
ATTACHMENT_MAX_LOCATION_LENGTH,
|
|
10
|
+
ATTACHMENT_REDIRECT_PATH,
|
|
11
|
+
ConnectionSettings,
|
|
12
|
+
)
|
|
13
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
14
|
+
from librus_python_api.message_content import validate_reference
|
|
15
|
+
from librus_python_api.models import (
|
|
16
|
+
MessageAttachmentReference,
|
|
17
|
+
ModernMessageAttachmentReference,
|
|
18
|
+
)
|
|
19
|
+
from librus_python_api.modern_mailbox import (
|
|
20
|
+
validate_reference as validate_modern_reference,
|
|
21
|
+
)
|
|
22
|
+
from librus_python_api.parsers import decode_json
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_attachment_reference(
|
|
26
|
+
reference: MessageAttachmentReference, account: str
|
|
27
|
+
) -> None:
|
|
28
|
+
if not isinstance(reference, MessageAttachmentReference):
|
|
29
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
30
|
+
validate_reference(reference.message, account)
|
|
31
|
+
if (
|
|
32
|
+
type(reference.identifier) is not str
|
|
33
|
+
or re.fullmatch(r"[0-9]{1,64}", reference.identifier) is None
|
|
34
|
+
):
|
|
35
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_key(key: str) -> None:
|
|
39
|
+
if (
|
|
40
|
+
type(key) is not str
|
|
41
|
+
or ATTACHMENT_KEY_PATTERN.fullmatch(key) is None
|
|
42
|
+
or key in {".", ".."}
|
|
43
|
+
):
|
|
44
|
+
raise LibrusError(ErrorKind.ACCESS_DENIED)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_modern_attachment_reference(
|
|
48
|
+
reference: ModernMessageAttachmentReference,
|
|
49
|
+
account: str,
|
|
50
|
+
) -> None:
|
|
51
|
+
if not isinstance(reference, ModernMessageAttachmentReference):
|
|
52
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
53
|
+
validate_modern_reference(reference.message, account)
|
|
54
|
+
if (
|
|
55
|
+
type(reference.archived) is not bool
|
|
56
|
+
or type(reference.identifier) is not str
|
|
57
|
+
or re.fullmatch(r"[0-9]{1,64}", reference.identifier) is None
|
|
58
|
+
):
|
|
59
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def modern_attachment_key(body: bytes, connection: ConnectionSettings) -> str:
|
|
63
|
+
data = decode_json(body)
|
|
64
|
+
if not isinstance(data, dict) or not isinstance(data.get("data"), dict):
|
|
65
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
66
|
+
return signed_attachment_key(data["data"].get("downloadLink"), connection)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_max_bytes(max_bytes: int) -> None:
|
|
70
|
+
if type(max_bytes) is not int or not 1 <= max_bytes <= ATTACHMENT_MAX_BYTES:
|
|
71
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def signed_attachment_key(location: str, connection: ConnectionSettings) -> str:
|
|
75
|
+
"""Only an exact absolute official destination or explicitly configured loopback."""
|
|
76
|
+
# Controls make parsing ambiguous; other encoding characters are judged only
|
|
77
|
+
# after origin/scheme/userinfo, so a foreign destination is always a denial.
|
|
78
|
+
if (
|
|
79
|
+
type(location) is not str
|
|
80
|
+
or not 1 <= len(location) <= ATTACHMENT_MAX_LOCATION_LENGTH
|
|
81
|
+
or any(ord(c) <= 32 or ord(c) >= 127 for c in location)
|
|
82
|
+
):
|
|
83
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
84
|
+
invalid = False
|
|
85
|
+
key = ""
|
|
86
|
+
try:
|
|
87
|
+
target = urlsplit(location)
|
|
88
|
+
expected = urlsplit(connection.download_origin)
|
|
89
|
+
authorities = {expected.netloc}
|
|
90
|
+
if expected.hostname == "sandbox.librus.pl":
|
|
91
|
+
authorities = {"sandbox.librus.pl", "sandbox.librus.pl:443"}
|
|
92
|
+
prefix, _, suffix = ATTACHMENT_REDIRECT_PATH.partition("{key}")
|
|
93
|
+
if (
|
|
94
|
+
target.scheme != expected.scheme
|
|
95
|
+
or target.netloc not in authorities
|
|
96
|
+
or target.username is not None
|
|
97
|
+
or target.password is not None
|
|
98
|
+
):
|
|
99
|
+
invalid = True
|
|
100
|
+
elif (
|
|
101
|
+
any(c in location for c in ("%", "\\", "?", "#"))
|
|
102
|
+
or not target.path.startswith(prefix)
|
|
103
|
+
or suffix
|
|
104
|
+
):
|
|
105
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
106
|
+
else:
|
|
107
|
+
key = target.path[len(prefix) :]
|
|
108
|
+
except ValueError:
|
|
109
|
+
invalid = True
|
|
110
|
+
if invalid:
|
|
111
|
+
raise LibrusError(ErrorKind.ACCESS_DENIED)
|
|
112
|
+
if ATTACHMENT_KEY_PATTERN.fullmatch(key) is None or key in {".", ".."}:
|
|
113
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
114
|
+
return key
|