annals 0.0.3__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.
annals/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """annals: an in-tray for AI agents. Publish documents and media to a private page; get a link.
2
+
3
+ Agents call :func:`annals.tools.publish` (or ``annals publish file.md`` from a shell) and hand
4
+ the printed URL to their human, who opens it on a phone. The documents are plain files in a
5
+ directory, local or on another machine over ssh; :func:`annals.api.mk_app` serves that
6
+ directory as a private page with search, sort, groups and a recycle bin.
7
+ """
8
+
9
+ from annals.config import Settings, load_settings
10
+ from annals.store import DocStore
11
+ from annals.target import LocalTarget, SshTarget, parse_target
12
+ from annals.tools import configure, group, groups, ls, publish, restore, show, trash
13
+
14
+ __all__ = [
15
+ "DocStore",
16
+ "LocalTarget",
17
+ "Settings",
18
+ "SshTarget",
19
+ "configure",
20
+ "group",
21
+ "groups",
22
+ "load_settings",
23
+ "ls",
24
+ "parse_target",
25
+ "publish",
26
+ "restore",
27
+ "show",
28
+ "trash",
29
+ ]
annals/__main__.py ADDED
@@ -0,0 +1,72 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """CLI entry point: ``annals publish report.md`` prints a link. Built by ``cw`` over :mod:`annals.tools`."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+
9
+ import cw
10
+
11
+ from annals.tools import _dispatch_funcs
12
+
13
+ #: the shape ``publish`` returns for one document; only that shape prints as a bare link
14
+ PUBLISH_KEYS = frozenset({"id", "title", "url", "group_id", "group_url", "warning"})
15
+ #: expected failures: one line on stderr and exit 1, no traceback
16
+ EXPECTED_ERRORS = (
17
+ ValueError,
18
+ KeyError,
19
+ FileNotFoundError,
20
+ PermissionError,
21
+ RuntimeError,
22
+ )
23
+
24
+
25
+ def _egress(result, *, out=None, err=None) -> int:
26
+ """Print a link when there is one (that is what an agent wants back), else JSON."""
27
+ out = out or sys.stdout
28
+ if result is None:
29
+ return 0
30
+ if isinstance(result, dict) and result.get("warning"):
31
+ print(f"annals: warning: {result.pop('warning')}", file=err or sys.stderr)
32
+ if isinstance(result, str):
33
+ print(result, file=out, end="" if result.endswith("\n") else "\n")
34
+ return 0
35
+ if isinstance(result, dict) and "url" in result and set(result) <= PUBLISH_KEYS:
36
+ print(result["url"], file=out)
37
+ if "group_url" in result and result["group_url"] != result["url"]:
38
+ print(result["group_url"], file=out)
39
+ return 0
40
+ if isinstance(result, dict) and "docs" in result and "group_url" in result:
41
+ print(result["group_url"], file=out)
42
+ for d in result["docs"]:
43
+ print(f" {d['url']} {d['title']}", file=out)
44
+ return 0
45
+ print(json.dumps(result, indent=1, ensure_ascii=False), file=out)
46
+ return 0
47
+
48
+
49
+ def run(argv=None, *, out=None, err=None) -> int:
50
+ """Dispatch ``argv`` (default ``sys.argv[1:]``) and return the exit code."""
51
+ try:
52
+ return cw.dispatch(
53
+ _dispatch_funcs,
54
+ argv,
55
+ prog="annals",
56
+ convention=cw.MODERN,
57
+ egress=_egress,
58
+ out=out,
59
+ err=err,
60
+ )
61
+ except EXPECTED_ERRORS as e:
62
+ print(f"annals: {type(e).__name__}: {e}", file=err or sys.stderr)
63
+ return 1
64
+
65
+
66
+ def main() -> None:
67
+ """Console-script entry point."""
68
+ raise SystemExit(run())
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
annals/api.py ADDED
@@ -0,0 +1,471 @@
1
+ """The HTTP surface: a JSON API over a :class:`annals.store.DocStore`, plus the page.
2
+
3
+ Two factories, one implementation:
4
+
5
+ * :func:`mk_api` is the API alone, rooted at ``/``: list, search, read, trash, restore,
6
+ purge, groups, raw files (``/raw/{id}/{path}``, with byte ranges so video seeks) and the
7
+ page's assets (``/ui/...``). A host platform mounts it under its own prefix and gates it
8
+ with its own login; that is how annals runs as an enlace app (``/api/annals``), with
9
+ the page shell from :func:`page_html` as the app's frontend.
10
+ * :func:`mk_app` is the standalone server ``annals serve`` runs: the API at
11
+ ``{base}/api``, the page at ``{base}/``, ``{base}/d/{id}``, ``{base}/g/{gid}``,
12
+ ``{base}/trash`` (one html shell; the browser routes), and an auth gate around it all
13
+ (see :mod:`annals.auth`).
14
+
15
+ The reader's actions are the three the recycle bin needs. Publishing is not an HTTP
16
+ operation here: it is a file write (see :mod:`annals.target`), which keeps this surface
17
+ small and the documents' lifecycle independent of whatever serves them.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import mimetypes
24
+ import os
25
+ import shutil
26
+ import tempfile
27
+ import warnings
28
+ from pathlib import Path
29
+ from typing import Callable
30
+ from urllib.parse import quote
31
+
32
+ import anyio
33
+ from fastapi import FastAPI, HTTPException, Request
34
+ from fastapi.responses import (
35
+ FileResponse,
36
+ HTMLResponse,
37
+ JSONResponse,
38
+ RedirectResponse,
39
+ Response,
40
+ )
41
+ from fastapi.staticfiles import StaticFiles
42
+
43
+ from annals.auth import Authorizer, authorizer_from_env, no_auth
44
+ from annals.config import (
45
+ APP_NAME,
46
+ DFLT_SERVE_PORT,
47
+ ENV_DATA_DIR,
48
+ MAX_INLINE_TEXT_BYTES,
49
+ default_data_dir,
50
+ )
51
+ from annals.store import DOCS, TRASH, DocStore, TEXT_KINDS, kind_of
52
+ from annals.target import check_rel
53
+
54
+ UI_DIR = Path(__file__).parent / "data" / "ui"
55
+ DFLT_BASE_PATH = f"/{APP_NAME}"
56
+ NO_STORE = {"Cache-Control": "no-store"}
57
+ # A document never changes after it is published (a new publish is a new id), so its
58
+ # files can be cached by the reader's browser; "private" keeps shared caches out of it.
59
+ RAW_CACHE = "private, max-age=86400"
60
+ # Every raw file is served under a sandbox policy, so a file that a browser renders as a
61
+ # document (html, svg, any +xml, whatever the host's mime table says) cannot read the
62
+ # platform's cookies or call any app's API on this shared origin. Harmless for media used
63
+ # by <img>/<video>. PDF alone is exempt: its built-in viewer refuses a sandboxed document.
64
+ NO_SANDBOX_TYPES = frozenset({"application/pdf"})
65
+ RAW_SANDBOX_CSP = (
66
+ "sandbox allow-scripts allow-popups allow-forms allow-modals allow-downloads"
67
+ )
68
+ # Types a browser would otherwise download or mis-render; served as text so a viewer can
69
+ # show them (markdown and friends are text to a reader).
70
+ #: thumbnail widths served; a request snaps up to the nearest, so the cache stays bounded
71
+ THUMB_WIDTHS = (160, 320, 640)
72
+ THUMB_CACHE = "cache/thumbs"
73
+ #: rasters Pillow can shrink; anything else (svg) is served as itself
74
+ THUMBNAILABLE = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".avif"})
75
+ TEXT_TYPE_OVERRIDES = {
76
+ ".md": "text/markdown; charset=utf-8",
77
+ ".markdown": "text/markdown; charset=utf-8",
78
+ }
79
+
80
+
81
+ class _RevalidatingStatic(StaticFiles):
82
+ """The page's assets: always revalidated, so a package upgrade reaches the browser."""
83
+
84
+ def file_response(self, *args, **kwargs) -> Response:
85
+ response = super().file_response(*args, **kwargs)
86
+ response.headers["Cache-Control"] = "no-cache"
87
+ return response
88
+
89
+
90
+ Thumbnailer = Callable[[Path, Path, int], bool]
91
+
92
+
93
+ #: at most this many thumbnails are made at once: the worker threads are the platform's
94
+ THUMB_CONCURRENCY = 2
95
+ #: refuse to decode images larger than this (pixels, after a JPEG's reduced-size draft)
96
+ THUMB_MAX_PIXELS = 60_000_000
97
+ #: when a thumbnail cannot be made, the original is sent only if it is at most this big
98
+ THUMB_FALLBACK_MAX_BYTES = 5_000_000
99
+ _TOO_BIG_SVG = (
100
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160"><rect width="160" height="160" fill="#888" '
101
+ 'opacity=".25"/><text x="80" y="76" text-anchor="middle" font-family="sans-serif" '
102
+ 'font-size="14" fill="#666">large image</text><text x="80" y="96" text-anchor="middle" font-family="sans-serif" '
103
+ 'font-size="12" fill="#666">tap to open</text></svg>'
104
+ )
105
+
106
+
107
+ def pillow_thumbnailer(src: Path, dst: Path, width: int) -> bool:
108
+ """Shrink ``src`` to ``width`` pixels wide as webp at ``dst``; ``False`` if it cannot.
109
+
110
+ Bounded, because it runs on threads every app shares: a JPEG is decoded at reduced
111
+ size (``draft``) before anything else, nothing over ``THUMB_MAX_PIXELS`` is decoded
112
+ after that, and it shrinks before rotating so the full-size image is never copied.
113
+ The caller bounds how many run at once (see ``mk_api``).
114
+ """
115
+ try:
116
+ from PIL import Image, ImageOps
117
+ except ImportError:
118
+ return False
119
+ if dst.is_file(): # made by another request while this one waited
120
+ return True
121
+ try:
122
+ with warnings.catch_warnings():
123
+ warnings.simplefilter(
124
+ "ignore", Image.DecompressionBombWarning
125
+ ) # the cap below decides
126
+ with Image.open(src) as im:
127
+ im.draft("RGB", (width, width * 4)) # JPEG: decode at 1/2..1/8 scale
128
+ if im.width * im.height > THUMB_MAX_PIXELS:
129
+ return False
130
+ im.thumbnail((width, width * 4))
131
+ im = ImageOps.exif_transpose(im)
132
+ if im.mode not in ("RGB", "RGBA"):
133
+ im = im.convert("RGBA")
134
+ dst.parent.mkdir(parents=True, exist_ok=True)
135
+ fd, tmp = tempfile.mkstemp(dir=dst.parent, suffix=".tmp")
136
+ try:
137
+ with os.fdopen(fd, "wb") as f:
138
+ im.save(f, "WEBP", quality=80, method=4)
139
+ os.replace(tmp, dst)
140
+ finally:
141
+ if os.path.exists(tmp):
142
+ os.unlink(tmp)
143
+ return True
144
+ except (OSError, ValueError, Image.DecompressionBombError):
145
+ return False
146
+
147
+
148
+ def _default_thumbnailer() -> Thumbnailer | None:
149
+ try:
150
+ import PIL # noqa: F401
151
+ except ImportError:
152
+ return None
153
+ return pillow_thumbnailer
154
+
155
+
156
+ def _list_meta(meta: dict) -> dict:
157
+ """A listing row: the meta without its per-file tables (a folder can hold thousands)."""
158
+ row = _with_thumb(dict(meta))
159
+ row["n_files"] = len(row.pop("files", []) or [])
160
+ row.pop("sizes", None)
161
+ return row
162
+
163
+
164
+ def _media_type(rel: str) -> str:
165
+ override = TEXT_TYPE_OVERRIDES.get(Path(rel).suffix.lower())
166
+ return override or mimetypes.guess_type(rel)[0] or "application/octet-stream"
167
+
168
+
169
+ def _with_thumb(meta: dict) -> dict:
170
+ """Add ``thumb`` (the first image, main file first) and fix pre-media kinds.
171
+
172
+ Documents published before media kinds existed say ``file`` for an image or a video;
173
+ their kind is recomputed from the main file so they show inline too.
174
+ """
175
+ if meta.get("kind") == "file" and meta.get("main"):
176
+ meta["kind"] = kind_of(meta["main"])
177
+ files = (
178
+ [meta["main"]] + meta.get("files", [])
179
+ if meta.get("main")
180
+ else meta.get("files", [])
181
+ )
182
+ meta["thumb"] = next((f for f in files if kind_of(f) == "image"), None)
183
+ return meta
184
+
185
+
186
+ def mk_api(
187
+ *,
188
+ data_dir: Path | str | None = None,
189
+ store: DocStore | None = None,
190
+ thumbnailer: Thumbnailer | None | bool = True,
191
+ title: str = APP_NAME,
192
+ ) -> FastAPI:
193
+ """The JSON API, raw files and page assets, rooted at ``/``, with no auth of its own.
194
+
195
+ Mount it behind a login (enlace_auth does this for an enlace app), or use
196
+ :func:`mk_app`, which adds the gate. Every argument has a working default.
197
+ ``thumbnailer`` shrinks gallery images: Pillow when it is installed (``True``), any
198
+ ``(src, dst, width) -> bool`` callable, or ``None`` to always serve the original.
199
+ """
200
+ store = store or DocStore(Path(data_dir) if data_dir else default_data_dir())
201
+ if thumbnailer is True:
202
+ thumbnailer = _default_thumbnailer()
203
+ api = FastAPI(title=f"{title} api", docs_url=None, redoc_url=None, openapi_url=None)
204
+ # No swagger: its /docs route would shadow ours.
205
+
206
+ @api.get("/health")
207
+ def health() -> dict:
208
+ """Cheap liveness probe (also says which data dir is served)."""
209
+ return {"ok": True, "target": store.target.describe()}
210
+
211
+ @api.get("/docs")
212
+ def list_docs(q: str = "", trash: bool = False) -> dict:
213
+ """All documents (or the bin), newest first; ``q`` filters by words."""
214
+ docs = store.search(q, trash=trash) if q else store.list(trash=trash)
215
+ return {"docs": [_list_meta(m) for m in docs]}
216
+
217
+ @api.get("/docs/{doc_id}")
218
+ def get_doc(doc_id: str) -> dict:
219
+ """One document's meta, each file's kind, and its text inline when markdown or text."""
220
+ meta = _with_thumb(_meta_or_404(store, doc_id))
221
+ meta["file_kinds"] = {f: kind_of(f) for f in meta.get("files", [])}
222
+ if meta["kind"] in TEXT_KINDS and meta.get("size", 0) <= MAX_INLINE_TEXT_BYTES:
223
+ meta["text"] = store.read_text(doc_id)
224
+ return meta
225
+
226
+ @api.post("/docs/{doc_id}/trash")
227
+ def trash_doc(doc_id: str) -> dict:
228
+ """Move a document to the recycle bin."""
229
+ _meta_or_404(store, doc_id)
230
+ return store.trash(doc_id)
231
+
232
+ @api.post("/docs/{doc_id}/restore")
233
+ def restore_doc(doc_id: str) -> dict:
234
+ """Bring a document back from the bin."""
235
+ _meta_or_404(store, doc_id)
236
+ return store.restore(doc_id)
237
+
238
+ @api.delete("/docs/{doc_id}")
239
+ def purge_doc(doc_id: str) -> dict:
240
+ """Delete forever; only allowed for a document already in the bin."""
241
+ _meta_or_404(store, doc_id)
242
+ try:
243
+ store.purge(doc_id)
244
+ except PermissionError as e:
245
+ raise HTTPException(409, str(e))
246
+ root = _local_root()
247
+ if root is not None:
248
+ shutil.rmtree(root / THUMB_CACHE / doc_id, ignore_errors=True)
249
+ return {"ok": True, "id": doc_id}
250
+
251
+ @api.get("/groups")
252
+ def list_groups() -> dict:
253
+ """Every group, newest first."""
254
+ return {"groups": store.list_groups()}
255
+
256
+ @api.get("/groups/{gid}")
257
+ def get_group(gid: str) -> dict:
258
+ """A group with the meta of each document it names (missing ones are skipped)."""
259
+ try:
260
+ group = store.group(gid)
261
+ except (KeyError, ValueError):
262
+ raise HTTPException(404, "no such group")
263
+ docs = []
264
+ for i in group["docs"]:
265
+ try:
266
+ docs.append(_list_meta(store.meta(i)))
267
+ except (KeyError, ValueError):
268
+ continue
269
+ return {**group, "items": docs}
270
+
271
+ def _local_root() -> Path | None:
272
+ return store.target.local_path(".")
273
+
274
+ def _file_path(doc_id: str, rel: str) -> Path:
275
+ """The file on disk, or a 404: it must be listed in the meta AND stay inside the
276
+ document's directory (meta.json is written by the publisher, so it is not trusted
277
+ to keep paths in bounds)."""
278
+ meta = _meta_or_404(store, doc_id)
279
+ if rel not in meta["files"]:
280
+ raise HTTPException(404, "no such file in document")
281
+ try:
282
+ check_rel(rel)
283
+ except ValueError:
284
+ raise HTTPException(404, "no such file in document")
285
+ doc_rel = f"{TRASH if meta['in_trash'] else DOCS}/{doc_id}"
286
+ doc_dir = store.target.local_path(doc_rel)
287
+ path = store.target.local_path(f"{doc_rel}/{rel}")
288
+ if doc_dir is None:
289
+ raise HTTPException(501, "raw files are served from a local data dir only")
290
+ if path is None or not path.is_relative_to(doc_dir) or not path.is_file():
291
+ raise HTTPException(404, "file missing on disk")
292
+ return path
293
+
294
+ @api.get("/raw/{doc_id}/{rel:path}")
295
+ def raw_file(doc_id: str, rel: str) -> Response:
296
+ """A document's file as itself, streamed, with byte ranges (video and audio seek).
297
+
298
+ ``FileResponse`` answers ``Range`` with ``206 Partial Content`` and never loads the
299
+ file into memory, which is what a ``<video>`` element needs (Safari will not play
300
+ without ranges).
301
+ """
302
+ path = _file_path(doc_id, rel)
303
+ media_type = _media_type(rel)
304
+ headers = {"Cache-Control": RAW_CACHE, "X-Content-Type-Options": "nosniff"}
305
+ if media_type.split(";")[0] not in NO_SANDBOX_TYPES:
306
+ headers["Content-Security-Policy"] = RAW_SANDBOX_CSP
307
+ return FileResponse(path, media_type=media_type, headers=headers)
308
+
309
+ # Waiting for a thumbnail slot must not hold one of the threads every app shares, so
310
+ # the endpoint is async and only the work itself goes to a thread, two at a time.
311
+ thumb_limiter = anyio.CapacityLimiter(THUMB_CONCURRENCY)
312
+
313
+ @api.get("/thumb/{doc_id}/{rel:path}")
314
+ async def thumb_file(doc_id: str, rel: str, w: int = 320) -> Response:
315
+ """A small webp of an image, made once and cached.
316
+
317
+ When none can be made, the original is sent if it is small, else a placeholder:
318
+ a phone never downloads a 60 MB photo for a 160 px tile.
319
+ """
320
+ path = await anyio.to_thread.run_sync(_file_path, doc_id, rel)
321
+ width = next((x for x in THUMB_WIDTHS if x >= w), THUMB_WIDTHS[-1])
322
+ if thumbnailer and Path(rel).suffix.lower() in THUMBNAILABLE:
323
+ key = hashlib.sha1(rel.encode("utf-8")).hexdigest()[:20]
324
+ cached = _local_root() / THUMB_CACHE / doc_id / f"{key}.w{width}.webp"
325
+ made = cached.is_file() or await anyio.to_thread.run_sync(
326
+ thumbnailer, path, cached, width, limiter=thumb_limiter
327
+ )
328
+ if made:
329
+ return FileResponse(
330
+ cached,
331
+ media_type="image/webp",
332
+ headers={"Cache-Control": RAW_CACHE},
333
+ )
334
+ if path.stat().st_size > THUMB_FALLBACK_MAX_BYTES:
335
+ return Response(
336
+ _TOO_BIG_SVG,
337
+ media_type="image/svg+xml",
338
+ headers={"Cache-Control": RAW_CACHE},
339
+ )
340
+ return raw_file(doc_id, rel)
341
+
342
+ if UI_DIR.is_dir(): # never fail a host's import over a missing asset dir
343
+ api.mount("/ui", _RevalidatingStatic(directory=str(UI_DIR)), name="ui")
344
+ api.state.store = store
345
+ return api
346
+
347
+
348
+ def page_html(
349
+ *, base: str = DFLT_BASE_PATH, api: str | None = None, title: str = APP_NAME
350
+ ) -> str:
351
+ """The page shell: where the page lives (``base``) and where its API is (``api``).
352
+
353
+ A host that serves the API under another prefix (enlace: ``/api/annals``) writes this
354
+ once as its frontend: ``annals page-shell --api /api/annals > frontend/index.html``.
355
+ The shell only names the two paths and loads the assets from the API, so the page
356
+ itself upgrades with the package, never with the shell.
357
+ """
358
+ base = "/" + base.strip("/")
359
+ api = "/" + (api or base + "/api").strip("/")
360
+ shell = (UI_DIR / "index.html").read_text(encoding="utf-8")
361
+ return (
362
+ shell.replace("{{BASE}}", base)
363
+ .replace("{{API}}", api)
364
+ .replace("{{TITLE}}", title)
365
+ )
366
+
367
+
368
+ def _wants_html(request: Request) -> bool:
369
+ return request.method == "GET" and "text/html" in request.headers.get("accept", "")
370
+
371
+
372
+ class _AuthGate:
373
+ """Pure ASGI middleware: ask the authorizer, deny like enlace_auth does."""
374
+
375
+ def __init__(self, app, authorizer: Authorizer):
376
+ self.app = app
377
+ self.authorizer = authorizer
378
+
379
+ async def __call__(self, scope, receive, send):
380
+ if scope["type"] != "http":
381
+ await self.app(scope, receive, send)
382
+ return
383
+ request = Request(scope)
384
+ who = self.authorizer(request)
385
+ if who is None:
386
+ await self._deny(request)(scope, receive, send)
387
+ return
388
+ scope.setdefault("state", {})["annals_user"] = who
389
+ await self.app(scope, receive, send)
390
+
391
+ def _deny(self, request: Request):
392
+ login_url = getattr(self.authorizer, "login_url", None)
393
+ if login_url and _wants_html(request):
394
+ nxt = request.url.path + (
395
+ f"?{request.url.query}" if request.url.query else ""
396
+ )
397
+ return RedirectResponse(
398
+ f"{login_url}?login_required=1&next={quote(nxt, safe='')}",
399
+ status_code=303,
400
+ )
401
+ headers = (
402
+ {"WWW-Authenticate": f'Basic realm="{APP_NAME}"'}
403
+ if login_url is None and self.authorizer is not no_auth
404
+ else {}
405
+ )
406
+ return JSONResponse(
407
+ {"detail": "Not authenticated", "auth": "user"},
408
+ status_code=401,
409
+ headers=headers,
410
+ )
411
+
412
+
413
+ def mk_app(
414
+ *,
415
+ data_dir: Path | str | None = None,
416
+ store: DocStore | None = None,
417
+ authorizer: Authorizer | None = None,
418
+ base_path: str = DFLT_BASE_PATH,
419
+ title: str = APP_NAME,
420
+ ) -> FastAPI:
421
+ """The standalone server: page + API under ``base_path``, behind ``authorizer``.
422
+
423
+ Every argument has a working default (the local data dir; auth from the environment,
424
+ which is none on a bare machine, so bind to localhost).
425
+ """
426
+ store = store or DocStore(Path(data_dir) if data_dir else default_data_dir())
427
+ authorizer = authorizer or authorizer_from_env()
428
+ base = "/" + base_path.strip("/")
429
+ app = FastAPI(title=title, docs_url=None, redoc_url=None, openapi_url=None)
430
+ shell = page_html(base=base, title=title)
431
+
432
+ @app.get(base + "/", response_class=HTMLResponse, include_in_schema=False)
433
+ @app.get(base + "/d/{doc_id}", response_class=HTMLResponse, include_in_schema=False)
434
+ @app.get(base + "/g/{gid}", response_class=HTMLResponse, include_in_schema=False)
435
+ @app.get(base + "/trash", response_class=HTMLResponse, include_in_schema=False)
436
+ def page(doc_id: str = "", gid: str = "") -> HTMLResponse:
437
+ return HTMLResponse(shell, headers={"Cache-Control": "no-cache"})
438
+
439
+ @app.get("/", include_in_schema=False)
440
+ @app.get(base, include_in_schema=False)
441
+ def root_redirect() -> RedirectResponse:
442
+ return RedirectResponse(base + "/", status_code=307)
443
+
444
+ app.mount(base + "/api", mk_api(store=store, title=title))
445
+ app.state.store = store
446
+ app.state.base_path = base
447
+ app.add_middleware(
448
+ _AuthGate, authorizer=authorizer
449
+ ) # pure ASGI, never BaseHTTPMiddleware
450
+ return app
451
+
452
+
453
+ def _meta_or_404(store: DocStore, doc_id: str) -> dict:
454
+ try:
455
+ return store.meta(doc_id)
456
+ except (KeyError, ValueError):
457
+ raise HTTPException(404, "no such document")
458
+
459
+
460
+ def serve(
461
+ *,
462
+ host: str = "127.0.0.1",
463
+ port: int = DFLT_SERVE_PORT,
464
+ data_dir: str | None = None,
465
+ base_path: str = DFLT_BASE_PATH,
466
+ ) -> None:
467
+ """Run the standalone server with uvicorn (``pip install 'annals[server]'``)."""
468
+ import uvicorn
469
+
470
+ app = mk_app(data_dir=data_dir or os.environ.get(ENV_DATA_DIR), base_path=base_path)
471
+ uvicorn.run(app, host=host, port=port, log_level="info")
annals/auth.py ADDED
@@ -0,0 +1,120 @@
1
+ """The auth seam: who may read the annals.
2
+
3
+ A annals is private by construction, so the server asks every request who is calling. The
4
+ answer comes from one of three ``Authorizer`` callables, chosen by :func:`authorizer_from_env`
5
+ (or passed to :func:`annals.api.mk_app`):
6
+
7
+ * :func:`no_auth`: everyone is the owner. Right for ``annals serve`` bound to ``127.0.0.1``,
8
+ wrong anywhere else.
9
+ * :class:`CookieWhoami`: forward the request's cookies to an identity endpoint that answers
10
+ ``{"email": ...}`` (enlace_auth's ``/auth/whoami``), and allow the listed emails. This is
11
+ how a annals sits behind an existing login without owning passwords: the login page, the
12
+ session cookie and the logout belong to the platform; the annals only checks the allowlist.
13
+ Env: ``ANNALS_WHOAMI_URL``, ``ANNALS_ALLOWED_USERS`` (comma separated), ``ANNALS_LOGIN_URL``.
14
+ * :class:`BasicAuth`: one username and password (HTTP Basic). For a annals with no platform
15
+ login in front of it. Env: ``ANNALS_BASIC_USER``, ``ANNALS_BASIC_PASSWORD``.
16
+
17
+ An authorizer returns the caller's identity (a string) or ``None``. The API turns ``None``
18
+ into a 303 to the login page for a browser GET, and a 401 for anything else, mirroring
19
+ what enlace_auth does so the two are indistinguishable from the phone.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import json
26
+ import os
27
+ import secrets
28
+ import urllib.error
29
+ import urllib.request
30
+ from typing import Callable, Optional, Protocol
31
+
32
+ ENV_WHOAMI_URL = "ANNALS_WHOAMI_URL"
33
+ ENV_ALLOWED_USERS = "ANNALS_ALLOWED_USERS"
34
+ ENV_LOGIN_URL = "ANNALS_LOGIN_URL"
35
+ ENV_BASIC_USER = "ANNALS_BASIC_USER"
36
+ ENV_BASIC_PASSWORD = "ANNALS_BASIC_PASSWORD"
37
+ DFLT_LOGIN_URL = "/auth/login"
38
+ WHOAMI_TIMEOUT_S = 3.0
39
+
40
+
41
+ class RequestLike(Protocol):
42
+ """The two things an authorizer reads from a request."""
43
+
44
+ @property
45
+ def headers(self): ... # Mapping[str, str], case-insensitive
46
+
47
+
48
+ Authorizer = Callable[[RequestLike], Optional[str]]
49
+
50
+
51
+ def no_auth(request: RequestLike) -> Optional[str]:
52
+ """Everyone is ``owner``. Only for a server that listens on localhost."""
53
+ return "owner"
54
+
55
+
56
+ class CookieWhoami:
57
+ """Ask an identity endpoint who holds this request's cookies; allow listed emails."""
58
+
59
+ def __init__(
60
+ self,
61
+ whoami_url: str,
62
+ allowed_users: list[str],
63
+ *,
64
+ login_url: str = DFLT_LOGIN_URL,
65
+ ):
66
+ self.whoami_url = whoami_url
67
+ self.allowed = {u.strip().lower() for u in allowed_users if u.strip()}
68
+ self.login_url = login_url
69
+
70
+ def __call__(self, request: RequestLike) -> Optional[str]:
71
+ cookie = request.headers.get("cookie")
72
+ if not cookie:
73
+ return None
74
+ req = urllib.request.Request(
75
+ self.whoami_url, headers={"Cookie": cookie, "Accept": "application/json"}
76
+ )
77
+ try:
78
+ with urllib.request.urlopen(req, timeout=WHOAMI_TIMEOUT_S) as resp:
79
+ body = json.loads(resp.read().decode("utf-8"))
80
+ except (urllib.error.URLError, json.JSONDecodeError, OSError):
81
+ return None
82
+ email = (body.get("email") or "").strip().lower()
83
+ return email if email and email in self.allowed else None
84
+
85
+
86
+ class BasicAuth:
87
+ """HTTP Basic with one username and password, compared in constant time."""
88
+
89
+ login_url = None # a 401 with WWW-Authenticate is the login page
90
+
91
+ def __init__(self, username: str, password: str):
92
+ self.username = username
93
+ self.password = password
94
+
95
+ def __call__(self, request: RequestLike) -> Optional[str]:
96
+ header = request.headers.get("authorization", "")
97
+ if not header.lower().startswith("basic "):
98
+ return None
99
+ try:
100
+ user, _, pw = base64.b64decode(header[6:]).decode("utf-8").partition(":")
101
+ except (ValueError, UnicodeDecodeError):
102
+ return None
103
+ ok = secrets.compare_digest(user, self.username) and secrets.compare_digest(
104
+ pw, self.password
105
+ )
106
+ return user if ok else None
107
+
108
+
109
+ def authorizer_from_env(env=None) -> Authorizer:
110
+ """Pick the authorizer the environment describes; see the module docstring."""
111
+ env = os.environ if env is None else env
112
+ whoami, allowed = env.get(ENV_WHOAMI_URL), env.get(ENV_ALLOWED_USERS)
113
+ if whoami and allowed:
114
+ return CookieWhoami(
115
+ whoami, allowed.split(","), login_url=env.get(ENV_LOGIN_URL, DFLT_LOGIN_URL)
116
+ )
117
+ user, pw = env.get(ENV_BASIC_USER), env.get(ENV_BASIC_PASSWORD)
118
+ if user and pw:
119
+ return BasicAuth(user, pw)
120
+ return no_auth