deltadeck 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.
- deltadeck/__init__.py +3 -0
- deltadeck/__main__.py +6 -0
- deltadeck/app.py +309 -0
- deltadeck/cli.py +111 -0
- deltadeck/config.py +394 -0
- deltadeck/desktop.py +301 -0
- deltadeck/docx.py +786 -0
- deltadeck/edit.py +650 -0
- deltadeck/edit_routes.py +275 -0
- deltadeck/errors.py +33 -0
- deltadeck/events.py +44 -0
- deltadeck/file_routes.py +183 -0
- deltadeck/fsops.py +143 -0
- deltadeck/git.py +810 -0
- deltadeck/git_routes.py +49 -0
- deltadeck/hidden.py +242 -0
- deltadeck/http_common.py +79 -0
- deltadeck/names.py +99 -0
- deltadeck/paths.py +377 -0
- deltadeck/pdf.py +377 -0
- deltadeck/readers.py +203 -0
- deltadeck/reconcile.py +413 -0
- deltadeck/recovery.py +371 -0
- deltadeck/roots.py +459 -0
- deltadeck/search.py +1280 -0
- deltadeck/seen.py +480 -0
- deltadeck/store.py +772 -0
- deltadeck/ui/404.html +1 -0
- deltadeck/ui/__next.__PAGE__.txt +35 -0
- deltadeck/ui/__next._full.txt +31 -0
- deltadeck/ui/__next._tree.txt +3 -0
- deltadeck/ui/_next/static/Gw6M6fxM3aEj0n-ZqJJ4a/_buildManifest.js +11 -0
- deltadeck/ui/_next/static/Gw6M6fxM3aEj0n-ZqJJ4a/_clientMiddlewareManifest.js +1 -0
- deltadeck/ui/_next/static/Gw6M6fxM3aEj0n-ZqJJ4a/_ssgManifest.js +1 -0
- deltadeck/ui/_next/static/chunks/0-wb0zlv4fraf.js +1 -0
- deltadeck/ui/_next/static/chunks/01p-raf8lqk27.js +1 -0
- deltadeck/ui/_next/static/chunks/032ykre2bnri8.js +1 -0
- deltadeck/ui/_next/static/chunks/0336mqc8l-u5_.js +1 -0
- deltadeck/ui/_next/static/chunks/04ez-o2l4o42j.js +4 -0
- deltadeck/ui/_next/static/chunks/04njl6dpcj5du.js +1 -0
- deltadeck/ui/_next/static/chunks/05_6295hyq3hs.js +1 -0
- deltadeck/ui/_next/static/chunks/05i_pb-4sod72.js +1 -0
- deltadeck/ui/_next/static/chunks/063x1fm-axugv.js +66 -0
- deltadeck/ui/_next/static/chunks/06bkit1w9xkil.js +1 -0
- deltadeck/ui/_next/static/chunks/06q08a4vhb65a.js +1 -0
- deltadeck/ui/_next/static/chunks/0ash-9bppuz64.js +1 -0
- deltadeck/ui/_next/static/chunks/0ba5gc5bhtd21.js +77 -0
- deltadeck/ui/_next/static/chunks/0bk68y6ulxiqc.css +1 -0
- deltadeck/ui/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- deltadeck/ui/_next/static/chunks/0ddvch5u_tkod.js +79 -0
- deltadeck/ui/_next/static/chunks/0ettfkje508xs.js +1 -0
- deltadeck/ui/_next/static/chunks/0g3bmow2o6ryj.js +1 -0
- deltadeck/ui/_next/static/chunks/0h-upsn6lazz9.js +1 -0
- deltadeck/ui/_next/static/chunks/0hpdbh5840flr.js +1 -0
- deltadeck/ui/_next/static/chunks/0knge03tyejx7.js +1 -0
- deltadeck/ui/_next/static/chunks/0npwoogk1edkj.js +1 -0
- deltadeck/ui/_next/static/chunks/0ooh5ltmcljc8.js +3 -0
- deltadeck/ui/_next/static/chunks/0p3iz33mgcaih.js +1 -0
- deltadeck/ui/_next/static/chunks/0pyjpjo88bqy7.js +1 -0
- deltadeck/ui/_next/static/chunks/0tvysmxvj5v0j.js +203 -0
- deltadeck/ui/_next/static/chunks/0udjrf1iq4fuk.js +1 -0
- deltadeck/ui/_next/static/chunks/0ukld36iqalgh.js +287 -0
- deltadeck/ui/_next/static/chunks/0va2a7jsjmv12.js +38 -0
- deltadeck/ui/_next/static/chunks/0vk-f147r1rsw.js +1 -0
- deltadeck/ui/_next/static/chunks/0w_drhom8r86n.js +1 -0
- deltadeck/ui/_next/static/chunks/0y2g0ctlf5oun.js +1 -0
- deltadeck/ui/_next/static/chunks/0y8yqekdf2p1s.js +1 -0
- deltadeck/ui/_next/static/chunks/0zcdw19u5g48m.js +2 -0
- deltadeck/ui/_next/static/chunks/1-eb6ubxl1zy0.js +1 -0
- deltadeck/ui/_next/static/chunks/1-srxa7hk2_y5.js +1 -0
- deltadeck/ui/_next/static/chunks/10vdv64yk0odn.js +31 -0
- deltadeck/ui/_next/static/chunks/10xdnyh3oo0e4.js +1 -0
- deltadeck/ui/_next/static/chunks/10xpbkeuur1_0.js +24 -0
- deltadeck/ui/_next/static/chunks/1178ms98k8h8x.js +10 -0
- deltadeck/ui/_next/static/chunks/123yrmmwha8uh.js +1 -0
- deltadeck/ui/_next/static/chunks/13jklb69rtm7g.js +86 -0
- deltadeck/ui/_next/static/chunks/14g2qr9-ac9_w.js +179 -0
- deltadeck/ui/_next/static/chunks/14uv7in3hq33c.js +1 -0
- deltadeck/ui/_next/static/chunks/15s134ttbtii8.js +1 -0
- deltadeck/ui/_next/static/chunks/18mh54xq67c88.js +1 -0
- deltadeck/ui/_next/static/chunks/1_gv4d2u9qmn5.js +1 -0
- deltadeck/ui/_next/static/chunks/1b2hau0_697gg.js +1 -0
- deltadeck/ui/_next/static/chunks/1cbv07r70is7e.js +1 -0
- deltadeck/ui/_next/static/chunks/1ck4x9qed_h2r.js +1 -0
- deltadeck/ui/_next/static/chunks/1cr8soqffviy4.js +1 -0
- deltadeck/ui/_next/static/chunks/1ctl-01dm3brq.js +1 -0
- deltadeck/ui/_next/static/chunks/1cxper-3md1o-.js +1 -0
- deltadeck/ui/_next/static/chunks/1d0je4el_be8s.js +36 -0
- deltadeck/ui/_next/static/chunks/1dcd-anbt29ze.js +1 -0
- deltadeck/ui/_next/static/chunks/1f1182516lh5d.js +1 -0
- deltadeck/ui/_next/static/chunks/1frzy0c-d3tf7.js +41 -0
- deltadeck/ui/_next/static/chunks/1ib4bqx1yadoq.js +1 -0
- deltadeck/ui/_next/static/chunks/1ijnbjhgkhviq.js +1 -0
- deltadeck/ui/_next/static/chunks/1jp_4giffrr5c.js +61 -0
- deltadeck/ui/_next/static/chunks/1kd8ih36je1cc.js +93 -0
- deltadeck/ui/_next/static/chunks/1khocfuulae2_.js +1 -0
- deltadeck/ui/_next/static/chunks/1ljfghevr8007.js +1 -0
- deltadeck/ui/_next/static/chunks/1mrguikl1mfmy.js +2 -0
- deltadeck/ui/_next/static/chunks/1n3avhvzagjqo.js +1 -0
- deltadeck/ui/_next/static/chunks/1nu18zblzzkxr.js +1 -0
- deltadeck/ui/_next/static/chunks/1nxtwvwc1-g9z.js +309 -0
- deltadeck/ui/_next/static/chunks/1oqyvtqy352xk.js +160 -0
- deltadeck/ui/_next/static/chunks/1qf05_j_wigan.js +135 -0
- deltadeck/ui/_next/static/chunks/1shqupz4psfrm.js +1 -0
- deltadeck/ui/_next/static/chunks/1su1--jeub0vd.js +1 -0
- deltadeck/ui/_next/static/chunks/1t67aekbcq1y3.js +1 -0
- deltadeck/ui/_next/static/chunks/1ubm9mtml7ye6.js +1 -0
- deltadeck/ui/_next/static/chunks/1vctqj-4qx0f1.js +32 -0
- deltadeck/ui/_next/static/chunks/1x65vx7fbh5f2.js +1 -0
- deltadeck/ui/_next/static/chunks/1xmdq3m655638.js +1 -0
- deltadeck/ui/_next/static/chunks/1y9mnk06touvm.js +1 -0
- deltadeck/ui/_next/static/chunks/1zgb46ecbigo9.js +1 -0
- deltadeck/ui/_next/static/chunks/2-936-wie2c7z.js +1 -0
- deltadeck/ui/_next/static/chunks/2-lhuoqw-8u6m.js +22 -0
- deltadeck/ui/_next/static/chunks/2168en1g_ff0m.js +1 -0
- deltadeck/ui/_next/static/chunks/22fvu1i8drv0y.js +1 -0
- deltadeck/ui/_next/static/chunks/22nye0w1-65or.js +138 -0
- deltadeck/ui/_next/static/chunks/23jcrmneulc-t.js +1 -0
- deltadeck/ui/_next/static/chunks/252yyuyz9o6wd.js +1 -0
- deltadeck/ui/_next/static/chunks/25dikvv_q4k84.js +1 -0
- deltadeck/ui/_next/static/chunks/26y99mhsiclgy.js +1 -0
- deltadeck/ui/_next/static/chunks/27ly0qb-et_jc.js +15 -0
- deltadeck/ui/_next/static/chunks/28g7ko42eee5a.js +1 -0
- deltadeck/ui/_next/static/chunks/29ig-8esac5vg.js +1 -0
- deltadeck/ui/_next/static/chunks/29k_24gthdxe3.js +1 -0
- deltadeck/ui/_next/static/chunks/29wydom8qwv9i.js +24 -0
- deltadeck/ui/_next/static/chunks/2coohofy6ysw4.js +5 -0
- deltadeck/ui/_next/static/chunks/2dfi-3n0im0zh.js +1 -0
- deltadeck/ui/_next/static/chunks/2fsxl6h9wu_5o.js +23 -0
- deltadeck/ui/_next/static/chunks/2idqz4xj9bcod.js +286 -0
- deltadeck/ui/_next/static/chunks/2iqj05imzhp4m.js +1 -0
- deltadeck/ui/_next/static/chunks/2itsqt_1jw8_y.js +1 -0
- deltadeck/ui/_next/static/chunks/2kaw65j578cef.js +1 -0
- deltadeck/ui/_next/static/chunks/2krtxbjoqmvi1.js +1 -0
- deltadeck/ui/_next/static/chunks/2n-qh2g6xqyon.js +1 -0
- deltadeck/ui/_next/static/chunks/2of5-a32h_fzo.js +1 -0
- deltadeck/ui/_next/static/chunks/2ovdd04qwksb2.js +1 -0
- deltadeck/ui/_next/static/chunks/2piucm-g64elp.js +1 -0
- deltadeck/ui/_next/static/chunks/2r4yjf9zlczgc.js +1 -0
- deltadeck/ui/_next/static/chunks/2v-nzhb17_fmx.js +15 -0
- deltadeck/ui/_next/static/chunks/2x5312oohk9i1.js +1 -0
- deltadeck/ui/_next/static/chunks/2yecjfgk9i16a.js +1 -0
- deltadeck/ui/_next/static/chunks/2yk59ik12pbte.js +1 -0
- deltadeck/ui/_next/static/chunks/2z3sh82dl8glu.js.map +5 -0
- deltadeck/ui/_next/static/chunks/2zin2_z0f0kdt.js +1 -0
- deltadeck/ui/_next/static/chunks/309-hcw850qmx.js +1 -0
- deltadeck/ui/_next/static/chunks/31kdobv016cs1.js +122 -0
- deltadeck/ui/_next/static/chunks/338p9ntbs06p9.js +1 -0
- deltadeck/ui/_next/static/chunks/36l9fbswts8w8.js +1 -0
- deltadeck/ui/_next/static/chunks/36rd86hnjy--8.js +34 -0
- deltadeck/ui/_next/static/chunks/371c8kx9yq-nr.js +62 -0
- deltadeck/ui/_next/static/chunks/37d5batav2n82.js +148 -0
- deltadeck/ui/_next/static/chunks/37uockrobxqrf.js +92 -0
- deltadeck/ui/_next/static/chunks/384kjvhilvq0i.js +1 -0
- deltadeck/ui/_next/static/chunks/38l10l8pa-kih.js +1 -0
- deltadeck/ui/_next/static/chunks/39il1efd5-s8w.js +94 -0
- deltadeck/ui/_next/static/chunks/3actn9as2ghnu.js +2 -0
- deltadeck/ui/_next/static/chunks/3czk1zwynepq1.js +104 -0
- deltadeck/ui/_next/static/chunks/3d2tm2i87o5p_.js +1 -0
- deltadeck/ui/_next/static/chunks/3ed96tb7kaefk.js +15 -0
- deltadeck/ui/_next/static/chunks/3fqx0481wur_n.js +1 -0
- deltadeck/ui/_next/static/chunks/3g8rs-0o1gmnz.js +1 -0
- deltadeck/ui/_next/static/chunks/3iermca84mhmd.js +1 -0
- deltadeck/ui/_next/static/chunks/3kv82kxg4c50f.js +64 -0
- deltadeck/ui/_next/static/chunks/3lb6zi3ns5rpz.js +1 -0
- deltadeck/ui/_next/static/chunks/3ok2kyuqnjk1x.js +115 -0
- deltadeck/ui/_next/static/chunks/3pl3fu8ycp70s.js +62 -0
- deltadeck/ui/_next/static/chunks/3ti94rnk8g3g9.js +15 -0
- deltadeck/ui/_next/static/chunks/3waspvyikyn8d.js +1 -0
- deltadeck/ui/_next/static/chunks/3wo0teggh8855.js +1 -0
- deltadeck/ui/_next/static/chunks/3x3y44ggx9amw.js +1 -0
- deltadeck/ui/_next/static/chunks/3x_p7gm_efbdf.js +212 -0
- deltadeck/ui/_next/static/chunks/40aaur_-qhj85.js +22 -0
- deltadeck/ui/_next/static/chunks/41jdd8wvvl-p8.js +4 -0
- deltadeck/ui/_next/static/chunks/41ngr_kzkqmbv.js +27 -0
- deltadeck/ui/_next/static/chunks/41ymya8gm_nwe.js +1 -0
- deltadeck/ui/_next/static/chunks/43p3o8p9_gmdm.js +1 -0
- deltadeck/ui/_next/static/chunks/44_sq5v-kidea.js +1 -0
- deltadeck/ui/_next/static/chunks/44oxs3_litch8.js +1 -0
- deltadeck/ui/_next/static/chunks/44t0wft6n2b5n.js +1 -0
- deltadeck/ui/_next/static/chunks/turbopack-0y0dee_nb28iz.js +1 -0
- deltadeck/ui/_next/static/chunks/turbopack-2c3mg3r-05n1w.js +1 -0
- deltadeck/ui/_next/static/chunks/turbopack-2ikcxs2-0edba.js +1 -0
- deltadeck/ui/_next/static/chunks/turbopack-worker-1hrsqshz0gfey.js +1 -0
- deltadeck/ui/_next/static/media/icon.15prq8m0ln7xb.svg +18 -0
- deltadeck/ui/_next/static/media/worker.2tknvk8mcp098.ts +40 -0
- deltadeck/ui/_next/static/media/worker.3o_k-bouwf2w4.ts +27 -0
- deltadeck/ui/_not-found/__next._full.txt +26 -0
- deltadeck/ui/_not-found/__next._not-found.__PAGE__.txt +32 -0
- deltadeck/ui/_not-found/__next._tree.txt +4 -0
- deltadeck/ui/_not-found.html +1 -0
- deltadeck/ui/_not-found.txt +26 -0
- deltadeck/ui/icon.svg +18 -0
- deltadeck/ui/index.html +1 -0
- deltadeck/ui/index.txt +31 -0
- deltadeck/ui/logo.svg +18 -0
- deltadeck/ui/print/__next._full.txt +31 -0
- deltadeck/ui/print/__next._tree.txt +4 -0
- deltadeck/ui/print/__next.print.__PAGE__.txt +37 -0
- deltadeck/ui/print.html +1 -0
- deltadeck/ui/print.txt +31 -0
- deltadeck-0.2.0.dist-info/METADATA +167 -0
- deltadeck-0.2.0.dist-info/RECORD +206 -0
- deltadeck-0.2.0.dist-info/WHEEL +4 -0
- deltadeck-0.2.0.dist-info/entry_points.txt +2 -0
- deltadeck-0.2.0.dist-info/licenses/LICENSE +21 -0
deltadeck/__init__.py
ADDED
deltadeck/__main__.py
ADDED
deltadeck/app.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""FastAPI application factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, Request
|
|
11
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
12
|
+
from fastapi.staticfiles import StaticFiles
|
|
13
|
+
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import Settings
|
|
17
|
+
from .edit import Editor
|
|
18
|
+
from .edit_routes import DEV_UI_PORT, MAX_UPLOAD_FILES, EditGate, is_edit_path, register_edit_routes
|
|
19
|
+
from .errors import register_error_handler
|
|
20
|
+
from .events import register_event_routes
|
|
21
|
+
from .file_routes import PDF_CSP, SVG_CSP, register_file_routes # noqa: F401 - CSPs re-exported
|
|
22
|
+
from .git import GitLink
|
|
23
|
+
from .git_routes import register_git_routes
|
|
24
|
+
from .hidden import GitIgnored, list_hidden_dir
|
|
25
|
+
from .http_common import ( # noqa: F401 - prefixes and path tests re-exported for older imports
|
|
26
|
+
ALLOWED_HOSTS,
|
|
27
|
+
ROOTS_PREFIX,
|
|
28
|
+
SEEN_PREFIX,
|
|
29
|
+
forwarded_hosts_ok,
|
|
30
|
+
is_gated_path,
|
|
31
|
+
is_roots_path,
|
|
32
|
+
is_seen_path,
|
|
33
|
+
same_site_request,
|
|
34
|
+
)
|
|
35
|
+
from .names import NAME_MAX
|
|
36
|
+
from .paths import PathGuard
|
|
37
|
+
from .pdf import PdfRenderer
|
|
38
|
+
from .reconcile import Reconciler, backstop_intervals, watch_loop
|
|
39
|
+
from .search import INDEX_DIR, MAX_COMPLETE, SearchIndex
|
|
40
|
+
from .store import Change, Store
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger("deltadeck")
|
|
43
|
+
|
|
44
|
+
CSP = ("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
|
|
45
|
+
"img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; "
|
|
46
|
+
"object-src 'none'; base-uri 'none'; form-action 'none'")
|
|
47
|
+
ALLOWED_METHODS = {"GET", "HEAD"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SecurityMiddleware:
|
|
51
|
+
"""Pure ASGI: refuse non-GET/HEAD (except below /api/edit, which EditGate guards, and, with
|
|
52
|
+
roots=True, below /api/roots and /api/seen, which RootGate guards), add security headers to
|
|
53
|
+
every response."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, app, *, roots: bool = False):
|
|
56
|
+
self.app = app
|
|
57
|
+
self.roots = roots
|
|
58
|
+
|
|
59
|
+
async def __call__(self, scope, receive, send):
|
|
60
|
+
if scope["type"] != "http":
|
|
61
|
+
return await self.app(scope, receive, send)
|
|
62
|
+
if (scope["method"] not in ALLOWED_METHODS and not is_edit_path(scope["path"])
|
|
63
|
+
and not (self.roots and is_gated_path(scope["path"]))):
|
|
64
|
+
body = b'{"detail":"Method Not Allowed"}'
|
|
65
|
+
await send({"type": "http.response.start", "status": 405, "headers": [
|
|
66
|
+
(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode()),
|
|
67
|
+
(b"allow", b"GET, HEAD"), (b"content-security-policy", CSP.encode()),
|
|
68
|
+
(b"referrer-policy", b"no-referrer"), (b"x-content-type-options", b"nosniff")]})
|
|
69
|
+
await send({"type": "http.response.body", "body": body})
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if not forwarded_hosts_ok(scope):
|
|
73
|
+
body = b'{"detail":"Invalid forwarded host"}'
|
|
74
|
+
await send({"type": "http.response.start", "status": 400, "headers": [
|
|
75
|
+
(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode()),
|
|
76
|
+
(b"content-security-policy", CSP.encode()),
|
|
77
|
+
(b"referrer-policy", b"no-referrer"), (b"x-content-type-options", b"nosniff")]})
|
|
78
|
+
await send({"type": "http.response.body", "body": body})
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
async def wrapped(message):
|
|
82
|
+
if message["type"] == "http.response.start":
|
|
83
|
+
headers = list(message.get("headers", []))
|
|
84
|
+
names = {k.lower() for k, _ in headers}
|
|
85
|
+
if b"content-security-policy" not in names:
|
|
86
|
+
headers.append((b"content-security-policy", CSP.encode()))
|
|
87
|
+
if b"referrer-policy" not in names:
|
|
88
|
+
headers.append((b"referrer-policy", b"no-referrer"))
|
|
89
|
+
if b"x-content-type-options" not in names:
|
|
90
|
+
headers.append((b"x-content-type-options", b"nosniff"))
|
|
91
|
+
message = {**message, "headers": headers}
|
|
92
|
+
await send(message)
|
|
93
|
+
|
|
94
|
+
await self.app(scope, receive, wrapped)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ActivityMiddleware:
|
|
98
|
+
"""Pure ASGI: each HTTP request (other than the live-update stream, which counts while it is
|
|
99
|
+
open) tells the reconciler someone is using the viewer."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, app, *, reconciler: Reconciler):
|
|
102
|
+
self.app = app
|
|
103
|
+
self.reconciler = reconciler
|
|
104
|
+
|
|
105
|
+
async def __call__(self, scope, receive, send):
|
|
106
|
+
if scope["type"] == "http" and scope["path"] != "/api/events":
|
|
107
|
+
self.reconciler.touch()
|
|
108
|
+
await self.app(scope, receive, send)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def find_repo_prefix(root: str) -> str:
|
|
112
|
+
cur = root
|
|
113
|
+
while True:
|
|
114
|
+
if os.path.lexists(os.path.join(cur, ".git")):
|
|
115
|
+
rel = os.path.relpath(root, cur)
|
|
116
|
+
return "" if rel == "." else rel.replace(os.sep, "/") + "/"
|
|
117
|
+
parent = os.path.dirname(cur)
|
|
118
|
+
if parent == cur:
|
|
119
|
+
return ""
|
|
120
|
+
cur = parent
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def config_payload(settings: Settings, *, root_present: bool, editor: Editor | None, git_enabled: bool,
|
|
124
|
+
repo_prefix: str = "") -> dict:
|
|
125
|
+
"""The /api/config body shared by a root's app and the no-folder app (RootSwitcher); the
|
|
126
|
+
caller adds the request-dependent tokens."""
|
|
127
|
+
root = settings.root if root_present else None
|
|
128
|
+
return {"root_name": os.path.basename(root) if root else None, "repo_prefix": repo_prefix,
|
|
129
|
+
"search_max_bytes": settings.search_max_bytes, "version": __version__,
|
|
130
|
+
"edit_enabled": editor is not None, "edit_off_reason": settings.edit_off_reason or None,
|
|
131
|
+
"deny_patterns": list(settings.deny),
|
|
132
|
+
"ignore_patterns": list(settings.ignore), "upload_ext": list(settings.upload_ext),
|
|
133
|
+
"upload_max_bytes": settings.upload_max_bytes, "edit_max_bytes": settings.edit_max_bytes,
|
|
134
|
+
"upload_max_files": MAX_UPLOAD_FILES, "root_path": root, "no_root": not root_present,
|
|
135
|
+
"root_switch_enabled": False, "git_enabled": git_enabled, "desktop": settings.desktop,
|
|
136
|
+
"reveal_enabled": settings.allow_reveal}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def mount_ui(app: FastAPI, ui_dir: str) -> None:
|
|
140
|
+
"""/print + the static export at /, or a 503 hint on / when the UI is not built."""
|
|
141
|
+
def get(path: str):
|
|
142
|
+
return app.api_route(path, methods=["GET", "HEAD"])
|
|
143
|
+
|
|
144
|
+
if os.path.isdir(ui_dir):
|
|
145
|
+
# The static export writes /print as print.html; StaticFiles does not add ".html".
|
|
146
|
+
@get("/print")
|
|
147
|
+
def ui_print():
|
|
148
|
+
f = os.path.join(ui_dir, "print.html")
|
|
149
|
+
if not os.path.isfile(f):
|
|
150
|
+
return JSONResponse({"detail": "print page not built; run 'npm run build'"}, status_code=404)
|
|
151
|
+
return FileResponse(f, media_type="text/html", headers={"Cache-Control": "no-cache"})
|
|
152
|
+
|
|
153
|
+
app.mount("/", StaticFiles(directory=ui_dir, html=True), name="ui")
|
|
154
|
+
else:
|
|
155
|
+
msg = (f"UI build not found at {ui_dir}. Run 'npm run build' in the web/ "
|
|
156
|
+
"directory; the API is still available under /api.")
|
|
157
|
+
log.warning(msg)
|
|
158
|
+
|
|
159
|
+
@get("/")
|
|
160
|
+
def ui_missing():
|
|
161
|
+
return JSONResponse({"detail": msg}, status_code=503)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def make_pdf_renderer(settings: Settings):
|
|
165
|
+
"""--desktop: rendered by the Electron shell over its bridge; else Playwright + system Chrome
|
|
166
|
+
(playwright itself is only imported on the first export)."""
|
|
167
|
+
if settings.desktop:
|
|
168
|
+
from .desktop import BridgeClient, BridgePdfRenderer # local: desktop imports roots imports app
|
|
169
|
+
|
|
170
|
+
return BridgePdfRenderer(settings.port, BridgeClient(settings.bridge_url, settings.bridge_token))
|
|
171
|
+
return PdfRenderer(settings.port)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def search_index_dir(settings: Settings) -> str | None:
|
|
175
|
+
"""<data_dir>/search, or None when the data dir overlaps the root (read-only mode never writes
|
|
176
|
+
inside the browsed folder; the index then goes to a temporary file, see deltadeck.search)."""
|
|
177
|
+
if settings.data_dir_overlaps_root():
|
|
178
|
+
return None
|
|
179
|
+
return os.path.join(settings.data_dir, INDEX_DIR)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def create_app(settings: Settings) -> FastAPI:
|
|
183
|
+
if settings.root is None:
|
|
184
|
+
raise ValueError("create_app needs a root (RootSwitcher serves the no-folder mode)")
|
|
185
|
+
settings.validate() # ConfigError: data dir overlaps the root while editing is enabled
|
|
186
|
+
guard = PathGuard(settings)
|
|
187
|
+
git = GitLink(settings, guard) # detected on first use (off: not a repo, no git, --git off)
|
|
188
|
+
ignored = GitIgnored(git.repo, guard)
|
|
189
|
+
store = Store(guard, ignored)
|
|
190
|
+
editor = Editor(settings, guard, store) if settings.allow_edit else None
|
|
191
|
+
index = SearchIndex(store, settings.search_max_bytes, index_dir=search_index_dir(settings),
|
|
192
|
+
max_files=settings.search_max_files, max_total_bytes=settings.search_max_total_bytes)
|
|
193
|
+
repo_prefix = find_repo_prefix(settings.root)
|
|
194
|
+
pdf = make_pdf_renderer(settings)
|
|
195
|
+
closing = asyncio.Event() # set on lifespan shutdown: ends this app's SSE streams (root switch)
|
|
196
|
+
|
|
197
|
+
async def on_change(ch: Change) -> None:
|
|
198
|
+
data = ch.to_json()
|
|
199
|
+
store.publish(ch)
|
|
200
|
+
await asyncio.to_thread(index.apply_change, data)
|
|
201
|
+
|
|
202
|
+
quick_s, full_s, remote = backstop_intervals(settings)
|
|
203
|
+
reconciler = Reconciler(store, on_change, quick_interval=quick_s, full_interval=full_s,
|
|
204
|
+
watching=settings.watch, pause_idle=not settings.rescan_when_idle, remote=remote)
|
|
205
|
+
store.on_subscribe = reconciler.client_connected
|
|
206
|
+
|
|
207
|
+
@contextlib.asynccontextmanager
|
|
208
|
+
async def lifespan(app: FastAPI):
|
|
209
|
+
store.bind_loop(asyncio.get_running_loop())
|
|
210
|
+
await asyncio.to_thread(ignored.refresh) # failing: shown as before, asked again at the next pass
|
|
211
|
+
await asyncio.to_thread(store.full_scan)
|
|
212
|
+
reconciler.reset()
|
|
213
|
+
index.start_background_build()
|
|
214
|
+
stop = asyncio.Event()
|
|
215
|
+
tasks = [asyncio.create_task(reconciler.run(stop))]
|
|
216
|
+
if settings.watch:
|
|
217
|
+
tasks.append(asyncio.create_task(watch_loop(store, on_change, stop, reconciler)))
|
|
218
|
+
log.info("change detection: %s; directory pass %s, full pass %s%s",
|
|
219
|
+
"file watcher" if settings.watch else "no file watcher",
|
|
220
|
+
f"every {quick_s:g} s" if quick_s else "off", f"every {full_s:g} s" if full_s else "off",
|
|
221
|
+
" (network volume)" if remote else "")
|
|
222
|
+
try:
|
|
223
|
+
yield
|
|
224
|
+
finally:
|
|
225
|
+
closing.set()
|
|
226
|
+
store.close_streams()
|
|
227
|
+
stop.set()
|
|
228
|
+
index.stop()
|
|
229
|
+
for t in tasks:
|
|
230
|
+
t.cancel()
|
|
231
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
232
|
+
await pdf.close()
|
|
233
|
+
|
|
234
|
+
app = FastAPI(
|
|
235
|
+
title="DeltaDeck", version=__version__, lifespan=lifespan,
|
|
236
|
+
docs_url=None, redoc_url=None, openapi_url=None,
|
|
237
|
+
telemetry={"tracing": False, "metrics": False, "logs": False,
|
|
238
|
+
"operation_spans": False, "auto_configure": False},
|
|
239
|
+
)
|
|
240
|
+
register_error_handler(app) # PathError, DocxError, PdfError, ...: {"detail"} with their status
|
|
241
|
+
app.state.settings = settings
|
|
242
|
+
app.state.store = store
|
|
243
|
+
app.state.index = index
|
|
244
|
+
app.state.pdf = pdf
|
|
245
|
+
app.state.editor = editor
|
|
246
|
+
app.state.closing = closing
|
|
247
|
+
app.state.git = git
|
|
248
|
+
app.state.reconciler = reconciler
|
|
249
|
+
app.state.config_extra = None # RootSwitcher: request -> {"root_switch_enabled", "root_token"?}
|
|
250
|
+
|
|
251
|
+
def get(path: str):
|
|
252
|
+
return app.api_route(path, methods=["GET", "HEAD"])
|
|
253
|
+
|
|
254
|
+
@get("/api/config")
|
|
255
|
+
def api_config(request: Request):
|
|
256
|
+
out = config_payload(settings, root_present=True, editor=editor, git_enabled=git.repo() is not None,
|
|
257
|
+
repo_prefix=repo_prefix)
|
|
258
|
+
if editor is not None and same_site_request(request):
|
|
259
|
+
out["edit_token"] = editor.token
|
|
260
|
+
if app.state.config_extra is not None:
|
|
261
|
+
out.update(app.state.config_extra(request))
|
|
262
|
+
return JSONResponse(out, headers={"Cache-Control": "no-store"})
|
|
263
|
+
|
|
264
|
+
@get("/api/tree")
|
|
265
|
+
def api_tree(hidden: str = "0"):
|
|
266
|
+
return store.snapshot(hidden=hidden in ("1", "true"), denied=settings.allow_reveal)
|
|
267
|
+
|
|
268
|
+
@get("/api/hidden")
|
|
269
|
+
def api_hidden(path: str | None = None):
|
|
270
|
+
"""The children of one hidden folder, listed when it is expanded (Show hidden files)."""
|
|
271
|
+
return list_hidden_dir(store, guard, path, include_denied=settings.allow_reveal)
|
|
272
|
+
|
|
273
|
+
register_file_routes(app, settings, guard, pdf, repo_prefix, store)
|
|
274
|
+
|
|
275
|
+
@get("/api/search")
|
|
276
|
+
def api_search(q: str = "", folder: str = "", types: str = "", limit: int = 50):
|
|
277
|
+
return index.search(q, folder=folder, types=types, limit=limit)
|
|
278
|
+
|
|
279
|
+
@get("/api/complete")
|
|
280
|
+
def api_complete(prefix: str = "", path: str = "", limit: int = MAX_COMPLETE):
|
|
281
|
+
"""Project-wide identifiers for editor completion, from the search index:
|
|
282
|
+
{"names": [{name, def, files}], "capped": bool}."""
|
|
283
|
+
if not 1 <= len(prefix) <= NAME_MAX:
|
|
284
|
+
return JSONResponse({"detail": f"prefix must be 1 to {NAME_MAX} characters"}, status_code=400)
|
|
285
|
+
if not 1 <= limit <= MAX_COMPLETE:
|
|
286
|
+
return JSONResponse({"detail": f"limit must be 1 to {MAX_COMPLETE}"}, status_code=400)
|
|
287
|
+
return index.complete(prefix, path=path, limit=limit)
|
|
288
|
+
|
|
289
|
+
register_event_routes(app, store, closing)
|
|
290
|
+
|
|
291
|
+
if editor is not None:
|
|
292
|
+
register_edit_routes(app, editor, on_change)
|
|
293
|
+
|
|
294
|
+
register_git_routes(app, git.repo, same_site_request)
|
|
295
|
+
|
|
296
|
+
@get("/api/{rest:path}")
|
|
297
|
+
def api_not_found(rest: str):
|
|
298
|
+
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
|
299
|
+
|
|
300
|
+
mount_ui(app, settings.ui_dir)
|
|
301
|
+
|
|
302
|
+
origins = {f"http://{h}:{p}" for h in ALLOWED_HOSTS for p in (settings.port, DEV_UI_PORT)}
|
|
303
|
+
app.add_middleware(EditGate, token=editor.token if editor else None, origins=origins,
|
|
304
|
+
json_max=settings.edit_max_bytes * 6 + 64 * 1024,
|
|
305
|
+
upload_max=settings.upload_max_bytes * MAX_UPLOAD_FILES + 1024 * 1024)
|
|
306
|
+
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
|
|
307
|
+
app.add_middleware(ActivityMiddleware, reconciler=reconciler)
|
|
308
|
+
app.add_middleware(SecurityMiddleware) # outermost: also covers TrustedHost rejections
|
|
309
|
+
return app
|
deltadeck/cli.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Console entry point: `uv run deltadeck`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from .config import HOST, settings_from_args
|
|
12
|
+
from .roots import RootSwitcher
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def self_check() -> None:
|
|
16
|
+
"""Hidden `--self-check` (packaging): the frozen binary has the app, SQLite FTS5, mammoth (.docx),
|
|
17
|
+
the file watcher's native backend (watchfiles) and no playwright."""
|
|
18
|
+
import importlib.util
|
|
19
|
+
import io
|
|
20
|
+
import sqlite3
|
|
21
|
+
import zipfile
|
|
22
|
+
|
|
23
|
+
from . import app, desktop # noqa: F401 - import errors are the check
|
|
24
|
+
from .docx import convert_docx
|
|
25
|
+
from .search import CONTENTLESS_DELETE, TOKENIZE
|
|
26
|
+
|
|
27
|
+
# store.py imports watchfiles lazily: check its native (FSEvents/inotify) extension made it into the bundle
|
|
28
|
+
from watchfiles import _rust_notify # noqa: F401
|
|
29
|
+
|
|
30
|
+
# the search index's table: contentless FTS5, its tokenizer, and contentless_delete when SQLite has it
|
|
31
|
+
db = sqlite3.connect(":memory:")
|
|
32
|
+
opts = ", contentless_delete=1" if CONTENTLESS_DELETE else ""
|
|
33
|
+
db.execute(f"CREATE VIRTUAL TABLE t USING fts5(body, content=''{opts}, tokenize='{TOKENIZE}')")
|
|
34
|
+
db.execute("INSERT INTO t (rowid, body) VALUES (1, 'hello frozen café')")
|
|
35
|
+
assert db.execute("SELECT count(*) FROM t WHERE t MATCH 'frozen AND cafe'").fetchone()[0] == 1
|
|
36
|
+
if CONTENTLESS_DELETE:
|
|
37
|
+
db.execute("DELETE FROM t WHERE rowid = 1")
|
|
38
|
+
assert db.execute("SELECT count(*) FROM t WHERE t MATCH 'frozen'").fetchone()[0] == 0
|
|
39
|
+
buf = io.BytesIO()
|
|
40
|
+
with zipfile.ZipFile(buf, "w") as z:
|
|
41
|
+
z.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
42
|
+
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
|
|
43
|
+
'relationships/officeDocument" Target="word/document.xml"/></Relationships>')
|
|
44
|
+
z.writestr("word/document.xml", '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/'
|
|
45
|
+
'2006/main"><w:body><w:p><w:r><w:t>frozen docx</w:t></w:r></w:p></w:body></w:document>')
|
|
46
|
+
assert convert_docx(buf.getvalue()).markdown == "frozen docx\n"
|
|
47
|
+
frozen = getattr(sys, "frozen", False)
|
|
48
|
+
if frozen and importlib.util.find_spec("playwright") is not None:
|
|
49
|
+
sys.exit("[deltadeck] self-check: playwright is bundled")
|
|
50
|
+
delete = "contentless_delete" if CONTENTLESS_DELETE else "no-contentless_delete"
|
|
51
|
+
print(f"DELTADECK_SELF_CHECK ok sqlite {sqlite3.sqlite_version} fts5 {delete} docx watchfiles"
|
|
52
|
+
f" python {sys.version.split()[0]}{' frozen' if frozen else ''}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: list[str] | None = None) -> None:
|
|
56
|
+
if (sys.argv[1:] if argv is None else argv) == ["--self-check"]:
|
|
57
|
+
return self_check()
|
|
58
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
59
|
+
settings = settings_from_args(argv)
|
|
60
|
+
if settings.desktop:
|
|
61
|
+
from .desktop import check_bridge
|
|
62
|
+
|
|
63
|
+
if err := check_bridge(settings.bridge_url, settings.bridge_token):
|
|
64
|
+
sys.exit(f"[deltadeck] {err}")
|
|
65
|
+
if settings.root is not None and not os.path.isdir(settings.root):
|
|
66
|
+
sys.exit(f"root folder not found: {settings.root}")
|
|
67
|
+
if settings.root is None and settings.lock_root:
|
|
68
|
+
sys.exit("[deltadeck] --lock-root needs a folder: pass --root PATH or set DELTADECK_ROOT")
|
|
69
|
+
if not os.path.isdir(settings.ui_dir):
|
|
70
|
+
print(f"[deltadeck] UI not built ({settings.ui_dir} missing). "
|
|
71
|
+
"Run 'npm run build' in web/ - serving the API only.", file=sys.stderr)
|
|
72
|
+
if settings.root is None:
|
|
73
|
+
print("[deltadeck] no folder selected — choose one in the browser", file=sys.stderr)
|
|
74
|
+
else:
|
|
75
|
+
src = {"state": " (last used)", "env": " (DELTADECK_ROOT)"}.get(settings.root_source, "")
|
|
76
|
+
print(f"[deltadeck] root: {settings.root}{src}", file=sys.stderr)
|
|
77
|
+
here = settings.editable() # this root only; `settings` stays the base for folder switches
|
|
78
|
+
if here.edit_off_reason:
|
|
79
|
+
print(f"[deltadeck] read-only here: {here.edit_off_reason}", file=sys.stderr)
|
|
80
|
+
elif here.allow_edit:
|
|
81
|
+
where = settings.store_dir if settings.root else f"{settings.data_dir}/roots/<key>"
|
|
82
|
+
print(f"[deltadeck] editing on (--read-only to turn off); history/trash/audit in {where}", file=sys.stderr)
|
|
83
|
+
elif settings.root is not None and settings.data_dir_overlaps_root():
|
|
84
|
+
print(f"[deltadeck] note: data dir {settings.data_dir} overlaps the root; it is hidden from the viewer", file=sys.stderr)
|
|
85
|
+
if settings.lock_root:
|
|
86
|
+
print("[deltadeck] folder switching disabled (--lock-root)", file=sys.stderr)
|
|
87
|
+
if settings.desktop:
|
|
88
|
+
return _run_desktop(settings)
|
|
89
|
+
print(f"[deltadeck] open http://{HOST}:{settings.port}/", file=sys.stderr)
|
|
90
|
+
app = RootSwitcher(settings)
|
|
91
|
+
uvicorn.run(app, host=HOST, port=settings.port, log_level="info", lifespan="on",
|
|
92
|
+
proxy_headers=False, server_header=False, timeout_graceful_shutdown=3)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _run_desktop(settings) -> None:
|
|
96
|
+
"""Child of the Electron shell: bridge picker + renderer, ready line on stdout, exit on stdin EOF."""
|
|
97
|
+
from .desktop import GRACEFUL_S, BridgeClient, bridge_picker, make_server, start_stdin_watchdog
|
|
98
|
+
|
|
99
|
+
print(f"[deltadeck] desktop mode; bridge {settings.bridge_url}", file=sys.stderr)
|
|
100
|
+
app = RootSwitcher(settings, picker=bridge_picker(BridgeClient(settings.bridge_url, settings.bridge_token)))
|
|
101
|
+
config = uvicorn.Config(app, host=HOST, port=settings.port, log_level="info", lifespan="on",
|
|
102
|
+
proxy_headers=False, server_header=False, timeout_graceful_shutdown=GRACEFUL_S)
|
|
103
|
+
server = make_server(config, settings.port)
|
|
104
|
+
start_stdin_watchdog(server)
|
|
105
|
+
server.run()
|
|
106
|
+
if not server.started:
|
|
107
|
+
sys.exit("[deltadeck] server did not start (see the log above)")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
main()
|