curu-memory 0.2.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.
curu_memory/browser.py ADDED
@@ -0,0 +1,396 @@
1
+ """The read-only browser: HTML pages made on the server (ADR-004).
2
+
3
+ The pages call the same `Retriever` as the MCP tools and the CLI. Each value from a
4
+ concept is escaped, each body goes through `render_markdown`, and each response has
5
+ a content security policy that blocks scripts. Each route accepts GET and HEAD only.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import hashlib
13
+ import hmac
14
+ import re
15
+ from collections.abc import Callable, Sequence
16
+ from html import escape
17
+ from pathlib import PurePosixPath
18
+ from typing import Any
19
+
20
+ import anyio
21
+ from starlette.applications import Starlette
22
+ from starlette.middleware import Middleware
23
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
24
+ from starlette.requests import Request
25
+ from starlette.responses import HTMLResponse, Response
26
+ from starlette.routing import Route
27
+
28
+ from curu_memory.access import ANONYMOUS, hidden_space
29
+ from curu_memory.audit import REFUSED, AuditLog
30
+ from curu_memory.config import Settings
31
+ from curu_memory.errors import ConceptNotFound, IndexMissing, InvalidArgument, NoReadableSpace
32
+ from curu_memory.ports import GLOBAL, Embedder
33
+ from curu_memory.render import concept_href, render_markdown
34
+ from curu_memory.retrieval import (
35
+ DEFAULT_LIMIT,
36
+ MAX_LIMIT,
37
+ MAX_QUESTION,
38
+ Retriever,
39
+ SearchResult,
40
+ retriever_for,
41
+ )
42
+ from curu_memory.store_sqlite import SqliteIndexStore
43
+
44
+ SECURITY_HEADERS = {
45
+ "Content-Security-Policy": (
46
+ "default-src 'none'; style-src 'self'; img-src 'self'; media-src 'self'; "
47
+ "form-action 'self'; "
48
+ "base-uri 'none'; frame-ancestors 'none'"
49
+ ),
50
+ "X-Content-Type-Options": "nosniff",
51
+ "Referrer-Policy": "no-referrer",
52
+ }
53
+ REALM = 'Basic realm="curu-memory"'
54
+ READ_METHODS = ("GET", "HEAD")
55
+
56
+ STYLE = """
57
+ body { font-family: system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem;
58
+ line-height: 1.5; color: #1f2328; }
59
+ header a { text-decoration: none; color: inherit; font-weight: 600; }
60
+ form { display: flex; gap: .5rem; margin: 1rem 0; }
61
+ input[type=search] { flex: 1; padding: .4rem; font-size: 1rem; }
62
+ .result { margin-bottom: 1rem; list-style: none; }
63
+ .type, .kind, .space { font-size: .8rem; color: #59636e; border: 1px solid #d1d9e0;
64
+ border-radius: 1rem; padding: 0 .5rem; }
65
+ .tags span { font-size: .8rem; background: #eef1f4; border-radius: .3rem; padding: 0 .3rem;
66
+ margin-right: .3rem; }
67
+ .notice { background: #fff8c5; padding: .5rem; border-radius: .3rem; }
68
+ .broken { color: #8c959f; text-decoration: line-through; }
69
+ .score { color: #59636e; font-size: .8rem; }
70
+ pre { background: #f6f8fa; padding: .5rem; overflow-x: auto; }
71
+ dt { font-weight: 600; }
72
+ figure { margin: 1rem 0; }
73
+ figure img { max-width: 100%; height: auto; border: 1px solid #d1d9e0; }
74
+ figcaption { font-size: .8rem; color: #59636e; }
75
+ """
76
+ #: A media address is the SHA-256 of the bytes: 64 lowercase hexadecimal characters.
77
+ SHA256 = re.compile(r"[0-9a-f]{64}")
78
+ #: The bytes of an address never change, so a web browser can keep them (research R-3).
79
+ MEDIA_CACHE = "private, max-age=31536000, immutable"
80
+ NO_MEDIA = "No media file has this address."
81
+ #: The range of a Range header that starts after the end of the file.
82
+ UNSATISFIABLE = (-1, -1)
83
+ _RANGE = re.compile(r"bytes=(\d*)-(\d*)")
84
+
85
+
86
+ def build_browser(
87
+ settings: Settings,
88
+ embedder: Embedder | None = None,
89
+ token: str | None = None,
90
+ readable: Callable[[], Sequence[str]] | None = None,
91
+ audit: AuditLog | None = None,
92
+ principal: str = ANONYMOUS.label,
93
+ ) -> Starlette:
94
+ """The browser app over the index of the settings.
95
+
96
+ With `token`, each request needs HTTP Basic authentication with that password.
97
+ `readable` gives the readable spaces of each request (ADR-016). Without it, the
98
+ reader reads `global` only. With `audit`, a refused read of a page or of media in a
99
+ space without a role writes one line for `principal` (FR-015).
100
+ """
101
+ store = SqliteIndexStore(settings.data_dir)
102
+ retriever = retriever_for(settings, store, embedder)
103
+ # Denied by default: with no callable, a caller reads global only (ADR-016).
104
+ spaces_of_request = readable or (lambda: (GLOBAL,))
105
+
106
+ def refused_all(action: str) -> None:
107
+ """A principal with no role in any space is a failed check (ADR-016)."""
108
+ if audit is not None:
109
+ audit.write(principal, action, None, REFUSED, "no role in any space")
110
+
111
+ def refused(action: str, spaces: Sequence[str], **target: str) -> None:
112
+ if audit is None:
113
+ return
114
+ space = hidden_space(store, spaces, **target)
115
+ if space is not None:
116
+ audit.write(principal, action, space, REFUSED, "no role in the space")
117
+
118
+ async def home(request: Request) -> Response:
119
+ response = await _search_page(retriever, request, spaces_of_request(), search=False)
120
+ if response.status_code == 403:
121
+ refused_all("page")
122
+ return response
123
+
124
+ async def search(request: Request) -> Response:
125
+ response = await _search_page(retriever, request, spaces_of_request(), search=True)
126
+ if response.status_code == 403:
127
+ refused_all("search")
128
+ return response
129
+
130
+ async def concept(request: Request) -> Response:
131
+ concept_id = request.path_params["concept_id"]
132
+ spaces = spaces_of_request()
133
+ try:
134
+ detail, links, known = await anyio.to_thread.run_sync(
135
+ lambda: _concept_parts(retriever, store, concept_id, spaces)
136
+ )
137
+ except IndexMissing as error:
138
+ return _page("No index", f"<p>{escape(error.message)}</p>", 503)
139
+ except NoReadableSpace as error:
140
+ refused_all("page")
141
+ return _page("No access", f"<p>{escape(error.message)}</p>", 403)
142
+ except ConceptNotFound:
143
+ await anyio.to_thread.run_sync(lambda: refused("page", spaces, concept_id=concept_id))
144
+ body = f"<p>No concept has the identifier <code>{escape(concept_id)}</code>.</p>"
145
+ return _page("Not found", body, 404)
146
+ return _page(detail.title, _concept_html(detail, links, known))
147
+
148
+ async def media(request: Request) -> Response:
149
+ digest = request.path_params["sha256"]
150
+ if not SHA256.fullmatch(digest):
151
+ return Response(NO_MEDIA, status_code=404, media_type="text/plain")
152
+ spaces = spaces_of_request()
153
+ found = await anyio.to_thread.run_sync(lambda: store.media_bytes(spaces, digest))
154
+ # The browser checks the bytes before it sends them (FR-006).
155
+ if found is None or hashlib.sha256(found[1]).hexdigest() != digest:
156
+ if found is None and not spaces:
157
+ refused_all("media")
158
+ elif found is None:
159
+ await anyio.to_thread.run_sync(lambda: refused("media", spaces, sha256=digest))
160
+ return Response(NO_MEDIA, status_code=404, media_type="text/plain")
161
+ mime, data = found
162
+ headers = {"Cache-Control": MEDIA_CACHE, "Accept-Ranges": "bytes"}
163
+ wanted = _byte_range(request.headers.get("range"), len(data))
164
+ if wanted is None:
165
+ return Response(data, media_type=mime, headers=headers)
166
+ if wanted == UNSATISFIABLE:
167
+ headers["Content-Range"] = f"bytes */{len(data)}"
168
+ return Response(status_code=416, headers=headers)
169
+ start, end = wanted
170
+ headers["Content-Range"] = f"bytes {start}-{end}/{len(data)}"
171
+ return Response(data[start : end + 1], status_code=206, media_type=mime, headers=headers)
172
+
173
+ async def style(request: Request) -> Response:
174
+ return Response(STYLE, media_type="text/css")
175
+
176
+ methods = list(READ_METHODS)
177
+ routes = [
178
+ Route("/", home, methods=methods),
179
+ Route("/search", search, methods=methods),
180
+ Route("/concepts/{concept_id:path}", concept, methods=methods),
181
+ Route("/static/style.css", style, methods=methods),
182
+ Route("/media/{sha256:path}", media, methods=methods),
183
+ ]
184
+ middleware = [Middleware(_Gate, token=token)]
185
+ return Starlette(routes=routes, middleware=middleware)
186
+
187
+
188
+ class _Gate(BaseHTTPMiddleware):
189
+ """The token check and the security headers of each response."""
190
+
191
+ def __init__(self, app: Any, token: str | None) -> None:
192
+ super().__init__(app)
193
+ self._token = token.encode() if token else None
194
+
195
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
196
+ # The browser only reads, so each other method gets 405 on each path, before the token.
197
+ if request.method not in READ_METHODS:
198
+ response: Response = Response("The browser only reads.", status_code=405)
199
+ response.headers["Allow"] = ", ".join(READ_METHODS)
200
+ elif self._token is not None and not self._allowed(request):
201
+ response = Response("Give the token as the password.", status_code=401)
202
+ response.headers["WWW-Authenticate"] = REALM
203
+ else:
204
+ response = await call_next(request)
205
+ response.headers.update(SECURITY_HEADERS)
206
+ return response
207
+
208
+ def _allowed(self, request: Request) -> bool:
209
+ scheme, _, value = request.headers.get("authorization", "").partition(" ")
210
+ if scheme.lower() != "basic":
211
+ return False
212
+ try:
213
+ decoded = base64.b64decode(value, validate=True).decode()
214
+ except (binascii.Error, UnicodeDecodeError):
215
+ return False
216
+ _user, _, password = decoded.partition(":")
217
+ assert self._token is not None
218
+ return hmac.compare_digest(password.encode(), self._token)
219
+
220
+
221
+ async def _search_page(
222
+ retriever: Retriever, request: Request, spaces: Sequence[str], *, search: bool
223
+ ) -> Response:
224
+ query = request.query_params
225
+ question = query.get("q", "")
226
+ types = query.getlist("type")
227
+ tags = query.getlist("tag")
228
+ try:
229
+ limit = int(query.get("limit", DEFAULT_LIMIT))
230
+ except ValueError:
231
+ limit = 0
232
+ form = _form(question, limit if 1 <= limit <= MAX_LIMIT else DEFAULT_LIMIT)
233
+ if not 1 <= limit <= MAX_LIMIT:
234
+ return _page("Search", form + f"<p>The limit must be from 1 to {MAX_LIMIT}.</p>", 400)
235
+ if len(question) > MAX_QUESTION:
236
+ text = f"<p>The question has more than {MAX_QUESTION} characters.</p>"
237
+ return _page("Search", form + text, 400)
238
+ try:
239
+ if not search or not question.strip():
240
+ await anyio.to_thread.run_sync(lambda: retriever.index_meta(spaces=spaces))
241
+ return _page("Search", form)
242
+ result = await anyio.to_thread.run_sync(
243
+ lambda: retriever.search(question, limit, types, tags, spaces=spaces)
244
+ )
245
+ except IndexMissing as error:
246
+ return _page("No index", f"<p>{escape(error.message)}</p>", 503)
247
+ except NoReadableSpace as error:
248
+ return _page("No access", f"<p>{escape(error.message)}</p>", 403)
249
+ except InvalidArgument as error:
250
+ return _page("Search", form + f"<p>{escape(error.message)}</p>", 400)
251
+ return _page(f"Search: {question}", form + _results_html(result))
252
+
253
+
254
+ def _form(question: str, limit: int) -> str:
255
+ return (
256
+ '<form method="get" action="/search">'
257
+ f'<input type="search" name="q" value="{escape(question)}" '
258
+ 'aria-label="Question" placeholder="Ask the knowledge" autofocus>'
259
+ f'<input type="hidden" name="limit" value="{limit}">'
260
+ "<button>Search</button></form>"
261
+ )
262
+
263
+
264
+ def _results_html(result: SearchResult) -> str:
265
+ parts = []
266
+ if result.notice:
267
+ parts.append(f'<p class="notice">{escape(result.notice)}</p>')
268
+ parts.append(f'<p class="score">Mode: {escape(result.mode)}</p><ol>')
269
+ for hit in result.results:
270
+ tags = "".join(f"<span>{escape(tag)}</span>" for tag in hit.tags)
271
+ parts.append(
272
+ f'<li class="result"><a href="{concept_href(hit.id)}">{escape(hit.title)}</a> '
273
+ f'<span class="type">{escape(hit.type)}</span> '
274
+ f'<span class="space">{escape(hit.space)}</span>'
275
+ f"<p>{escape(hit.description)}</p>"
276
+ f'<p class="tags">{tags} <span class="score">score {hit.score:.4f}</span></p></li>'
277
+ )
278
+ parts.append("</ol>")
279
+ return "".join(parts)
280
+
281
+
282
+ def _concept_parts(
283
+ retriever: Retriever, store: SqliteIndexStore, concept_id: str, spaces: Sequence[str]
284
+ ) -> Any:
285
+ detail = retriever.get_concept(concept_id, spaces=spaces)
286
+ links = retriever.neighbours(concept_id, spaces=spaces)
287
+ targets = _md_targets(detail.body)
288
+ # A link to a concept in a space that is not readable shows as a missing link.
289
+ known = set(store.titles(spaces, targets))
290
+ return detail, links, known
291
+
292
+
293
+ _MD_TARGET = re.compile(r"\]\(\s*([^\s)]+\.md)")
294
+
295
+
296
+ def _md_targets(body: str) -> list[str]:
297
+ """The identifiers of the concept files that a body links to."""
298
+ return [PurePosixPath(target).stem for target in _MD_TARGET.findall(body)]
299
+
300
+
301
+ def _concept_html(detail: Any, links: Any, known: set[str]) -> str:
302
+ tags = "".join(f"<span>{escape(tag)}</span>" for tag in detail.tags)
303
+ sources = "".join(_source_html(source) for source in detail.sources)
304
+ fields = (
305
+ f"<dl><dt>Identifier</dt><dd><code>{escape(detail.id)}</code></dd>"
306
+ f"<dt>Space</dt><dd><code>{escape(detail.space)}</code></dd>"
307
+ f"<dt>Type</dt><dd>{escape(detail.type)}</dd>"
308
+ f"<dt>Status</dt><dd>{escape(detail.status or 'none')}</dd>"
309
+ f"<dt>File</dt><dd><code>{escape(detail.path)}</code></dd>"
310
+ f'<dt>Tags</dt><dd class="tags">{tags}</dd></dl>'
311
+ )
312
+ return (
313
+ f'<p class="description">{escape(detail.description)}</p>{fields}'
314
+ f'<div class="body">{render_markdown(detail.body, known)}</div>'
315
+ f"{_media_html(detail.media)}"
316
+ f"<h2>Sources</h2><ul>{sources or '<li>None</li>'}</ul>"
317
+ f'<h2 id="outgoing">Outgoing links</h2>{_links_html(links.outgoing)}'
318
+ f'<h2 id="incoming">Incoming links</h2>{_links_html(links.incoming)}'
319
+ )
320
+
321
+
322
+ def _byte_range(header: str | None, size: int) -> tuple[int, int] | None:
323
+ """The (first, last) byte of one range of a Range header, UNSATISFIABLE, or None.
324
+
325
+ A web browser asks for parts of a clip to play it and to seek in it. A header with
326
+ no range, with more than one range or with another unit gets the whole file (None).
327
+ """
328
+ found = _RANGE.fullmatch(header.strip()) if header else None
329
+ if found is None or found.group(0) == "bytes=-":
330
+ return None
331
+ first, last = found.groups()
332
+ if not first:
333
+ start, end = max(size - int(last), 0), size - 1
334
+ else:
335
+ start = int(first)
336
+ end = min(int(last), size - 1) if last else size - 1
337
+ if start >= size or start > end:
338
+ return UNSATISFIABLE
339
+ return start, end
340
+
341
+
342
+ def _media_html(media: list[Any]) -> str:
343
+ """A figure for each file, in list order: an image, an audio player or a video player.
344
+
345
+ The path is the caption, and the alt text of an image.
346
+ """
347
+ if not media:
348
+ return ""
349
+ figures = []
350
+ for item in media:
351
+ source = f"/media/{escape(item.sha256)}"
352
+ path = escape(item.path)
353
+ if item.mime.startswith("audio/"):
354
+ element = f'<audio controls preload="none" src="{source}"></audio>'
355
+ elif item.mime.startswith("video/"):
356
+ element = f'<video controls preload="none" src="{source}"></video>'
357
+ else:
358
+ element = f'<img src="{source}" alt="{path}" loading="lazy">'
359
+ figures.append(f"<figure>{element}<figcaption>{path}</figcaption></figure>")
360
+ return '<h2 id="media">Media</h2>' + "".join(figures)
361
+
362
+
363
+ def _source_html(source: dict[str, Any]) -> str:
364
+ label = escape(str(source.get("title") or source.get("id") or "source"))
365
+ details = [
366
+ escape(str(source[key]))
367
+ for key in ("resource", "author", "last_modified")
368
+ if source.get(key)
369
+ ]
370
+ return f"<li>{label}" + (f" ({', '.join(details)})" if details else "") + "</li>"
371
+
372
+
373
+ def _links_html(links: list[Any]) -> str:
374
+ if not links:
375
+ return "<p>None.</p>"
376
+ items = []
377
+ for link in links:
378
+ kind = f'<span class="kind">{escape(link.kind)}</span> '
379
+ if link.resolved:
380
+ title = escape(link.title or link.id)
381
+ items.append(f'<li>{kind}<a href="{concept_href(link.id)}">{title}</a></li>')
382
+ else:
383
+ items.append(f'<li>{kind}<span class="broken">{escape(link.id)}</span> (missing)</li>')
384
+ return "<ul>" + "".join(items) + "</ul>"
385
+
386
+
387
+ def _page(title: str, body: str, status: int = 200) -> HTMLResponse:
388
+ html = (
389
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">'
390
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
391
+ f"<title>{escape(title)} · curu-memory</title>"
392
+ '<link rel="stylesheet" href="/static/style.css"></head><body>'
393
+ '<header><a href="/">curu-memory</a></header>'
394
+ f"<h1>{escape(title)}</h1>{body}</body></html>"
395
+ )
396
+ return HTMLResponse(html, status_code=status)