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 +29 -0
- annals/__main__.py +72 -0
- annals/api.py +471 -0
- annals/auth.py +120 -0
- annals/config.py +121 -0
- annals/data/skills/annals-publish/SKILL.md +67 -0
- annals/data/ui/app.js +418 -0
- annals/data/ui/index.html +18 -0
- annals/data/ui/marked.min.js +69 -0
- annals/data/ui/purify.min.js +3 -0
- annals/data/ui/style.css +138 -0
- annals/mcp.py +32 -0
- annals/store.py +377 -0
- annals/target.py +248 -0
- annals/tools.py +256 -0
- annals-0.0.3.dist-info/METADATA +135 -0
- annals-0.0.3.dist-info/RECORD +20 -0
- annals-0.0.3.dist-info/WHEEL +4 -0
- annals-0.0.3.dist-info/entry_points.txt +2 -0
- annals-0.0.3.dist-info/licenses/LICENSE +21 -0
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
|