mfup-fastapi 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.
- mfup_fastapi/__init__.py +40 -0
- mfup_fastapi/__main__.py +13 -0
- mfup_fastapi/app.py +18 -0
- mfup_fastapi/config.py +78 -0
- mfup_fastapi/engine.py +1118 -0
- mfup_fastapi/py.typed +0 -0
- mfup_fastapi-0.2.0.dist-info/METADATA +92 -0
- mfup_fastapi-0.2.0.dist-info/RECORD +10 -0
- mfup_fastapi-0.2.0.dist-info/WHEEL +4 -0
- mfup_fastapi-0.2.0.dist-info/licenses/LICENSE +21 -0
mfup_fastapi/engine.py
ADDED
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
"""MfupEngine — the FastAPI-facing shell around the mfup-core engine.
|
|
2
|
+
|
|
3
|
+
Embedding into an existing application:
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from mfup_fastapi import MfupConfig, MfupEngine
|
|
7
|
+
|
|
8
|
+
engine = MfupEngine(MfupConfig(
|
|
9
|
+
base_dir=Path("/srv/uploads"),
|
|
10
|
+
redis_url="redis://localhost:6379/0",
|
|
11
|
+
authorize=my_authorize, # a callable — or "pkg.mod:func"
|
|
12
|
+
))
|
|
13
|
+
app = FastAPI(lifespan=engine.lifespan)
|
|
14
|
+
app.include_router(engine.router, prefix="/api/uploads")
|
|
15
|
+
|
|
16
|
+
The router keeps the protocol's fixed "/mfup/*" namespace under whatever
|
|
17
|
+
prefix it is mounted at; point the browser client's serverUrl at the same
|
|
18
|
+
prefix ("https://host/api/uploads") and every path lines up. No module-level
|
|
19
|
+
state: two engines in one process are two independent instances.
|
|
20
|
+
|
|
21
|
+
The standalone server (``python -m mfup_fastapi`` / mfup_fastapi.app:app)
|
|
22
|
+
is a thin wrapper: ``create_app(MfupConfig.from_env())``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import logging
|
|
29
|
+
import json
|
|
30
|
+
import secrets
|
|
31
|
+
import shutil
|
|
32
|
+
import time
|
|
33
|
+
from contextlib import asynccontextmanager
|
|
34
|
+
from datetime import datetime, timedelta, timezone
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, AsyncIterator, Optional
|
|
37
|
+
|
|
38
|
+
from fastapi import APIRouter, FastAPI, Request, WebSocket, WebSocketDisconnect, status
|
|
39
|
+
from fastapi.responses import JSONResponse
|
|
40
|
+
|
|
41
|
+
from mfup_core.hooks import (
|
|
42
|
+
AuthRequest,
|
|
43
|
+
AuthorizeHook,
|
|
44
|
+
CommitEvent,
|
|
45
|
+
FileMapRequest,
|
|
46
|
+
MapFileHook,
|
|
47
|
+
OnCommittedHook,
|
|
48
|
+
load_authorize_hook,
|
|
49
|
+
resolve_hook,
|
|
50
|
+
)
|
|
51
|
+
from mfup_core.protocol import CRC32C_IMPL, PROTOCOL_VERSION, FrameReader, SessionEndFrame, SessionState
|
|
52
|
+
from mfup_core.session_manager import SessionRegistry, LiveSession
|
|
53
|
+
from mfup_core.publish import (
|
|
54
|
+
ConflictError,
|
|
55
|
+
MappingError,
|
|
56
|
+
list_payload_files,
|
|
57
|
+
publish_session,
|
|
58
|
+
publish_session_mapped,
|
|
59
|
+
)
|
|
60
|
+
from mfup_core.redis_index import SessionIndex
|
|
61
|
+
from mfup_core.storage import staging_dir
|
|
62
|
+
|
|
63
|
+
from .config import MfupConfig
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger("mfup.engine")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Typed publish errors — raised by MfupEngine.publish(), mapped to HTTP by
|
|
70
|
+
# the endpoint; consumer backends calling publish() directly catch them.
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
class PublishError(Exception):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SessionNotFound(PublishError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class NotCommitted(PublishError):
|
|
82
|
+
def __init__(self, state: str) -> None:
|
|
83
|
+
super().__init__(f"cannot publish session in state {state}")
|
|
84
|
+
self.state = state
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TargetEscapes(PublishError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class MapFileHookError(PublishError):
|
|
92
|
+
def __init__(self, path: str) -> None:
|
|
93
|
+
super().__init__(f"map_file hook raised for {path}")
|
|
94
|
+
self.path = path
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Retention safety net (kept as a free function — tests exercise it directly)
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
async def reconcile_orphans(
|
|
102
|
+
base: Path,
|
|
103
|
+
registry: SessionRegistry,
|
|
104
|
+
index: SessionIndex,
|
|
105
|
+
prefix: str,
|
|
106
|
+
grace_seconds: int = 600,
|
|
107
|
+
) -> list[str]:
|
|
108
|
+
"""Remove staging dirs that no live session and no Redis entry reference.
|
|
109
|
+
|
|
110
|
+
The zset-driven sweeper can only clean sessions Redis still knows about.
|
|
111
|
+
A staging dir becomes an unreachable orphan when cleanup is interrupted
|
|
112
|
+
(crash between rmtree and ZREM), when rmtree silently failed
|
|
113
|
+
(ignore_errors), or when Redis lost the entry (flush/eviction). Such a
|
|
114
|
+
dir can never be resumed (resume needs the session in the registry, which
|
|
115
|
+
at startup is populated only from Redis) nor swept — so it would
|
|
116
|
+
accumulate forever. This scan is the retention safety net.
|
|
117
|
+
|
|
118
|
+
An orphan is removed only when ALL hold, to avoid racing a live upload:
|
|
119
|
+
- not in the in-memory registry,
|
|
120
|
+
- not scored in the Redis session zset,
|
|
121
|
+
- last modified at least `grace_seconds` ago.
|
|
122
|
+
"""
|
|
123
|
+
removed: list[str] = []
|
|
124
|
+
if not base.exists():
|
|
125
|
+
return removed
|
|
126
|
+
|
|
127
|
+
now = time.time()
|
|
128
|
+
for entry in base.iterdir():
|
|
129
|
+
name = entry.name
|
|
130
|
+
if not entry.is_dir() or not name.startswith(f"{prefix}."):
|
|
131
|
+
continue
|
|
132
|
+
sid = name[len(prefix) + 1:]
|
|
133
|
+
if registry.get(sid) is not None:
|
|
134
|
+
continue # live in this process
|
|
135
|
+
try:
|
|
136
|
+
if await index.is_registered(sid):
|
|
137
|
+
continue # Redis still tracks it — sweeper owns it
|
|
138
|
+
except Exception:
|
|
139
|
+
# Redis unreachable — do not delete anything we cannot verify.
|
|
140
|
+
logger.warning("Reconcile: cannot verify session %s in Redis, skipping", sid)
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
age = now - entry.stat().st_mtime
|
|
144
|
+
except OSError:
|
|
145
|
+
continue
|
|
146
|
+
if age < grace_seconds:
|
|
147
|
+
continue # too fresh — might be a session mid-registration
|
|
148
|
+
|
|
149
|
+
shutil.rmtree(str(entry), ignore_errors=True)
|
|
150
|
+
# Belt and suspenders: drop any half-written Redis entry too.
|
|
151
|
+
try:
|
|
152
|
+
await index.remove(sid)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
removed.append(sid)
|
|
156
|
+
|
|
157
|
+
return removed
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_safe_target(base_dir: Path, target_dir: str) -> bool:
|
|
161
|
+
"""Check that target_dir resolves within base_dir (no path traversal)."""
|
|
162
|
+
target = Path(target_dir)
|
|
163
|
+
if target.is_absolute():
|
|
164
|
+
resolved = target.resolve()
|
|
165
|
+
else:
|
|
166
|
+
resolved = (base_dir / target).resolve()
|
|
167
|
+
try:
|
|
168
|
+
resolved.relative_to(base_dir.resolve())
|
|
169
|
+
return True
|
|
170
|
+
except ValueError:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Engine
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
class MfupEngine:
|
|
179
|
+
"""Owns the registry, the Redis index, the sweeper, the hooks, and an
|
|
180
|
+
APIRouter with every MFUP endpoint. All state is instance state."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, config: MfupConfig) -> None:
|
|
183
|
+
self.cfg = config
|
|
184
|
+
|
|
185
|
+
# Hooks: callables pass through, dotted paths import loudly at
|
|
186
|
+
# construction time — a misconfigured hook must not boot.
|
|
187
|
+
if config.authorize is None:
|
|
188
|
+
self.authorize: AuthorizeHook | None = load_authorize_hook(None) # logs the allow-all warning
|
|
189
|
+
else:
|
|
190
|
+
self.authorize = resolve_hook(config.authorize) # type: ignore[assignment]
|
|
191
|
+
self.map_file: MapFileHook | None = resolve_hook(config.map_file) # type: ignore[assignment]
|
|
192
|
+
self.on_committed: OnCommittedHook | None = resolve_hook(config.on_committed) # type: ignore[assignment]
|
|
193
|
+
|
|
194
|
+
self.registry: SessionRegistry | None = None
|
|
195
|
+
self.index: SessionIndex | None = None
|
|
196
|
+
self._sweep_task: asyncio.Task | None = None
|
|
197
|
+
|
|
198
|
+
self.router: APIRouter = self._build_router()
|
|
199
|
+
|
|
200
|
+
# -- lifecycle -----------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
async def startup(self) -> None:
|
|
203
|
+
cfg = self.cfg
|
|
204
|
+
cfg.base_dir.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
|
|
206
|
+
self.index = SessionIndex(cfg.redis_url)
|
|
207
|
+
self.registry = SessionRegistry(
|
|
208
|
+
cfg.base_dir,
|
|
209
|
+
staging_prefix=cfg.staging_prefix,
|
|
210
|
+
session_resume_ttl=cfg.session_resume_ttl,
|
|
211
|
+
leg_idle_timeout=cfg.leg_idle_timeout,
|
|
212
|
+
max_chunk_bytes=cfg.max_chunk_bytes,
|
|
213
|
+
# With a map_file hook the client's layout no longer predicts
|
|
214
|
+
# final paths — the ingest-time conflict ASK would be noise;
|
|
215
|
+
# publish-time conflict handling (409 → action) takes over.
|
|
216
|
+
conflict_check=(self.map_file is None),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Recover live sessions from Redis index → SQLite DBs
|
|
220
|
+
try:
|
|
221
|
+
alive_ids = await self.index.get_not_expired()
|
|
222
|
+
expired_ids = await self.index.get_expired()
|
|
223
|
+
logger.info(
|
|
224
|
+
"Startup: Redis has %d alive + %d expired session(s)",
|
|
225
|
+
len(alive_ids), len(expired_ids),
|
|
226
|
+
)
|
|
227
|
+
recovered = 0
|
|
228
|
+
for sid in alive_ids:
|
|
229
|
+
meta = await self.index.get_meta(sid)
|
|
230
|
+
if not meta or not meta.staging_dir:
|
|
231
|
+
logger.warning("Startup: no meta for session %s, skipping", sid)
|
|
232
|
+
continue
|
|
233
|
+
session = await self.registry.recover_session(sid, Path(meta.staging_dir))
|
|
234
|
+
if session:
|
|
235
|
+
idx = self.index
|
|
236
|
+
session._on_expiry_change = lambda s, e, _idx=idx: _idx.update_expiry(s, e)
|
|
237
|
+
recovered += 1
|
|
238
|
+
logger.info("Startup: recovered %d session(s) from disk", recovered)
|
|
239
|
+
except Exception:
|
|
240
|
+
logger.exception("Startup: session recovery failed")
|
|
241
|
+
|
|
242
|
+
# One filesystem reconciliation at startup: catches staging dirs
|
|
243
|
+
# orphaned by a crash between rmtree and Redis-remove, a silently-
|
|
244
|
+
# failed rmtree, or a Redis flush — none of which the zset-driven
|
|
245
|
+
# sweeper can ever find.
|
|
246
|
+
try:
|
|
247
|
+
removed = await reconcile_orphans(
|
|
248
|
+
cfg.base_dir, self.registry, self.index, cfg.staging_prefix,
|
|
249
|
+
cfg.orphan_grace_seconds,
|
|
250
|
+
)
|
|
251
|
+
if removed:
|
|
252
|
+
logger.warning("Startup: reconciled %d orphaned staging dir(s): %s", len(removed), removed)
|
|
253
|
+
except Exception:
|
|
254
|
+
logger.exception("Startup: orphan reconciliation failed")
|
|
255
|
+
|
|
256
|
+
self._sweep_task = asyncio.create_task(self._sweeper())
|
|
257
|
+
|
|
258
|
+
async def shutdown(self) -> None:
|
|
259
|
+
if self._sweep_task is not None:
|
|
260
|
+
self._sweep_task.cancel()
|
|
261
|
+
try:
|
|
262
|
+
await self._sweep_task
|
|
263
|
+
except asyncio.CancelledError:
|
|
264
|
+
pass
|
|
265
|
+
self._sweep_task = None
|
|
266
|
+
if self.index is not None:
|
|
267
|
+
await self.index.close()
|
|
268
|
+
|
|
269
|
+
@asynccontextmanager
|
|
270
|
+
async def lifespan(self, app: FastAPI) -> AsyncIterator[None]:
|
|
271
|
+
"""Drop-in FastAPI lifespan: ``FastAPI(lifespan=engine.lifespan)``.
|
|
272
|
+
|
|
273
|
+
Consumers with their own lifespan call startup()/shutdown() from it
|
|
274
|
+
instead.
|
|
275
|
+
"""
|
|
276
|
+
await self.startup()
|
|
277
|
+
try:
|
|
278
|
+
yield
|
|
279
|
+
finally:
|
|
280
|
+
await self.shutdown()
|
|
281
|
+
|
|
282
|
+
def _require_registry(self) -> SessionRegistry:
|
|
283
|
+
assert self.registry is not None, "MfupEngine.startup() has not run"
|
|
284
|
+
return self.registry
|
|
285
|
+
|
|
286
|
+
def _require_index(self) -> SessionIndex:
|
|
287
|
+
assert self.index is not None, "MfupEngine.startup() has not run"
|
|
288
|
+
return self.index
|
|
289
|
+
|
|
290
|
+
# -- periodic sweeper ------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
async def _sweeper(self) -> None:
|
|
293
|
+
cfg = self.cfg
|
|
294
|
+
sweeps = 0
|
|
295
|
+
while True:
|
|
296
|
+
await asyncio.sleep(cfg.sweep_interval)
|
|
297
|
+
try:
|
|
298
|
+
registry = self._require_registry()
|
|
299
|
+
index = self._require_index()
|
|
300
|
+
expired_ids = await index.get_expired()
|
|
301
|
+
for sid in expired_ids:
|
|
302
|
+
await registry.remove(sid)
|
|
303
|
+
meta = await index.get_meta(sid)
|
|
304
|
+
if meta and meta.staging_dir:
|
|
305
|
+
sd = Path(meta.staging_dir)
|
|
306
|
+
else:
|
|
307
|
+
sd = staging_dir(cfg.base_dir, sid, cfg.staging_prefix)
|
|
308
|
+
if sd.exists():
|
|
309
|
+
shutil.rmtree(str(sd), ignore_errors=True)
|
|
310
|
+
await index.remove(sid)
|
|
311
|
+
logger.info("Sweeper cleaned session %s (staging=%s)", sid, sd)
|
|
312
|
+
if expired_ids:
|
|
313
|
+
logger.info("Sweeper removed %d sessions", len(expired_ids))
|
|
314
|
+
|
|
315
|
+
sweeps += 1
|
|
316
|
+
if sweeps % cfg.reconcile_every == 0:
|
|
317
|
+
orphans = await reconcile_orphans(
|
|
318
|
+
cfg.base_dir, registry, index, cfg.staging_prefix,
|
|
319
|
+
cfg.orphan_grace_seconds,
|
|
320
|
+
)
|
|
321
|
+
if orphans:
|
|
322
|
+
logger.warning("Sweeper reconciled %d orphaned staging dir(s): %s", len(orphans), orphans)
|
|
323
|
+
except Exception:
|
|
324
|
+
logger.exception("Sweeper error")
|
|
325
|
+
|
|
326
|
+
# -- programmatic publish ---------------------------------------------------
|
|
327
|
+
|
|
328
|
+
async def publish(self, session_id: str) -> list[str]:
|
|
329
|
+
"""Publish a committed session server-side (consumer backends and the
|
|
330
|
+
on_committed auto-publish path). Raises typed PublishError subclasses;
|
|
331
|
+
ConflictError/MappingError from mfup-core pass through."""
|
|
332
|
+
registry = self._require_registry()
|
|
333
|
+
session = registry.get(session_id)
|
|
334
|
+
if session is None:
|
|
335
|
+
raise SessionNotFound(session_id)
|
|
336
|
+
if session.state != SessionState.COMMITTED:
|
|
337
|
+
raise NotCommitted(session.state.value)
|
|
338
|
+
|
|
339
|
+
target = Path(session.target_dir)
|
|
340
|
+
if not target.is_absolute():
|
|
341
|
+
target = session.base_dir / target
|
|
342
|
+
|
|
343
|
+
# Defense in depth: verify target stays within the session's base dir
|
|
344
|
+
if not _is_safe_target(session.base_dir, session.target_dir):
|
|
345
|
+
raise TargetEscapes(session.target_dir)
|
|
346
|
+
|
|
347
|
+
if self.map_file is not None:
|
|
348
|
+
# Per-file layout is the consumer's: run the (async) hook per
|
|
349
|
+
# file first, then hand the precomputed plan to the sync mover.
|
|
350
|
+
files = await asyncio.to_thread(
|
|
351
|
+
list_payload_files, session.base_dir, session_id, self.cfg.staging_prefix,
|
|
352
|
+
)
|
|
353
|
+
mapping: dict[str, str] = {}
|
|
354
|
+
for rel, size in files:
|
|
355
|
+
try:
|
|
356
|
+
mapped = await self.map_file(FileMapRequest(
|
|
357
|
+
session_id=session_id,
|
|
358
|
+
path=rel,
|
|
359
|
+
name=rel.rsplit("/", 1)[-1],
|
|
360
|
+
size=size,
|
|
361
|
+
target_dir=session.target_dir,
|
|
362
|
+
meta=session.client_meta,
|
|
363
|
+
context=session.auth_context,
|
|
364
|
+
))
|
|
365
|
+
except Exception:
|
|
366
|
+
logger.exception("map_file hook raised for %s (%s)", session_id, rel)
|
|
367
|
+
raise MapFileHookError(rel) from None
|
|
368
|
+
if mapped is not None:
|
|
369
|
+
mapping[rel] = mapped
|
|
370
|
+
published = await asyncio.to_thread(
|
|
371
|
+
publish_session_mapped,
|
|
372
|
+
session.base_dir, session_id, target, mapping, self.cfg.staging_prefix,
|
|
373
|
+
session.publish_action,
|
|
374
|
+
)
|
|
375
|
+
else:
|
|
376
|
+
# publish_session is synchronous filesystem work (renames / merge
|
|
377
|
+
# walks) — keep it off the event loop.
|
|
378
|
+
published = await asyncio.to_thread(
|
|
379
|
+
publish_session,
|
|
380
|
+
session.base_dir, session_id, target, self.cfg.staging_prefix,
|
|
381
|
+
session.publish_action,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
await registry.remove(session_id)
|
|
385
|
+
await self._require_index().remove(session_id)
|
|
386
|
+
return published
|
|
387
|
+
|
|
388
|
+
# -- on_committed ----------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
async def _after_commit(self, session: LiveSession, result: dict) -> None:
|
|
391
|
+
"""Fire the consumer's on_committed hook once per commit. A hook error
|
|
392
|
+
must never damage the session; 'publish' return triggers server-side
|
|
393
|
+
publish immediately."""
|
|
394
|
+
if self.on_committed is None:
|
|
395
|
+
return
|
|
396
|
+
sd = staging_dir(session.base_dir, session.session_id, self.cfg.staging_prefix)
|
|
397
|
+
try:
|
|
398
|
+
verdict = await self.on_committed(CommitEvent(
|
|
399
|
+
session_id=session.session_id,
|
|
400
|
+
target_dir=session.target_dir,
|
|
401
|
+
base_dir=str(session.base_dir),
|
|
402
|
+
staging_dir=str(sd),
|
|
403
|
+
files=result["files"],
|
|
404
|
+
bytes=result["bytes"],
|
|
405
|
+
meta=session.client_meta,
|
|
406
|
+
context=session.auth_context,
|
|
407
|
+
))
|
|
408
|
+
except Exception:
|
|
409
|
+
logger.exception("on_committed hook raised for session %s", session.session_id)
|
|
410
|
+
return
|
|
411
|
+
if verdict == "publish":
|
|
412
|
+
try:
|
|
413
|
+
published = await self.publish(session.session_id)
|
|
414
|
+
logger.info(
|
|
415
|
+
"on_committed auto-published session %s: %s",
|
|
416
|
+
session.session_id, published,
|
|
417
|
+
)
|
|
418
|
+
except Exception:
|
|
419
|
+
logger.exception("on_committed auto-publish failed for session %s", session.session_id)
|
|
420
|
+
|
|
421
|
+
async def _try_commit(self, session: LiveSession) -> Optional[dict]:
|
|
422
|
+
"""try_commit + on_committed, so no call site can forget the hook."""
|
|
423
|
+
result = await session.try_commit()
|
|
424
|
+
if result is not None and session.state == SessionState.COMMITTED:
|
|
425
|
+
await self._after_commit(session, result)
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
# -- router ----------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
def _build_router(self) -> APIRouter: # noqa: C901 — one closure per endpoint
|
|
431
|
+
router = APIRouter()
|
|
432
|
+
engine = self
|
|
433
|
+
cfg = self.cfg
|
|
434
|
+
|
|
435
|
+
# -------------------------------------------------------------------
|
|
436
|
+
# WebSocket control endpoint: /mfup/control
|
|
437
|
+
# -------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
@router.websocket("/mfup/control")
|
|
440
|
+
async def control_endpoint(ws: WebSocket) -> None:
|
|
441
|
+
await ws.accept()
|
|
442
|
+
registry = engine._require_registry()
|
|
443
|
+
session: LiveSession | None = None
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
# First message must be HELLO or RESUME
|
|
447
|
+
msg = await ws.receive_json()
|
|
448
|
+
t = msg.get("t")
|
|
449
|
+
|
|
450
|
+
if t == "HELLO":
|
|
451
|
+
if msg.get("v") != PROTOCOL_VERSION:
|
|
452
|
+
await ws.send_json({
|
|
453
|
+
"t": "SESSION_ABORT",
|
|
454
|
+
"code": "bad_version",
|
|
455
|
+
"reason": f"expected {PROTOCOL_VERSION}",
|
|
456
|
+
})
|
|
457
|
+
await ws.close()
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
session_id = msg["session_id"]
|
|
461
|
+
leg_id = msg["leg_id"]
|
|
462
|
+
target_dir = msg.get("target_dir", ".")
|
|
463
|
+
# The resume token is SERVER-issued (returned in HELLO_OK)
|
|
464
|
+
# — a client-chosen token would be a self-signed
|
|
465
|
+
# credential, useless as an authenticator for the
|
|
466
|
+
# data/publish endpoints.
|
|
467
|
+
resume_token = secrets.token_urlsafe(32)
|
|
468
|
+
|
|
469
|
+
# Client-attached session metadata (scope/purpose/ids).
|
|
470
|
+
# Untrusted; size-capped; handed to authorize + map_file.
|
|
471
|
+
client_meta = msg.get("meta")
|
|
472
|
+
meta_json: str | None = None
|
|
473
|
+
if client_meta is not None:
|
|
474
|
+
meta_json = json.dumps(client_meta, ensure_ascii=False)
|
|
475
|
+
if len(meta_json.encode()) > cfg.max_meta_bytes:
|
|
476
|
+
await ws.send_json({
|
|
477
|
+
"t": "SESSION_ABORT",
|
|
478
|
+
"code": "protocol_error",
|
|
479
|
+
"reason": f"meta exceeds {cfg.max_meta_bytes} bytes",
|
|
480
|
+
})
|
|
481
|
+
await ws.close()
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
# Consumer authorization (see mfup_core.hooks). Runs
|
|
485
|
+
# BEFORE anything is created. Deny → auth_failed.
|
|
486
|
+
auth_result = None
|
|
487
|
+
if engine.authorize is not None:
|
|
488
|
+
try:
|
|
489
|
+
auth_result = await engine.authorize(AuthRequest(
|
|
490
|
+
session_id=session_id,
|
|
491
|
+
target_dir=target_dir,
|
|
492
|
+
headers=dict(ws.headers),
|
|
493
|
+
client=f"{ws.client.host}:{ws.client.port}" if ws.client else "",
|
|
494
|
+
query=dict(ws.query_params),
|
|
495
|
+
meta=client_meta,
|
|
496
|
+
))
|
|
497
|
+
except Exception:
|
|
498
|
+
logger.exception("Authorize hook raised for session %s — denying", session_id)
|
|
499
|
+
auth_result = None
|
|
500
|
+
if auth_result is None:
|
|
501
|
+
await ws.send_json({
|
|
502
|
+
"t": "SESSION_ABORT",
|
|
503
|
+
"code": "auth_failed",
|
|
504
|
+
"reason": "authorization denied",
|
|
505
|
+
})
|
|
506
|
+
await ws.close()
|
|
507
|
+
return
|
|
508
|
+
if auth_result.target_dir is not None:
|
|
509
|
+
# The hook may pin or MAP the target (it received
|
|
510
|
+
# the client-requested value in req.target_dir);
|
|
511
|
+
# still subject to the containment check below.
|
|
512
|
+
target_dir = auth_result.target_dir
|
|
513
|
+
|
|
514
|
+
# Per-session base directory (e.g. the user's home).
|
|
515
|
+
# Staging is created INSIDE it so publish remains a
|
|
516
|
+
# same-filesystem rename even on separate mounts.
|
|
517
|
+
session_base = registry.base_dir
|
|
518
|
+
if auth_result is not None and auth_result.base_dir is not None:
|
|
519
|
+
candidate = Path(auth_result.base_dir)
|
|
520
|
+
if not candidate.is_absolute():
|
|
521
|
+
logger.error(
|
|
522
|
+
"Authorize hook returned a relative base_dir %r for session %s — denying",
|
|
523
|
+
auth_result.base_dir, session_id,
|
|
524
|
+
)
|
|
525
|
+
await ws.send_json({
|
|
526
|
+
"t": "SESSION_ABORT",
|
|
527
|
+
"code": "auth_failed",
|
|
528
|
+
"reason": "authorization misconfigured (relative base_dir)",
|
|
529
|
+
})
|
|
530
|
+
await ws.close()
|
|
531
|
+
return
|
|
532
|
+
session_base = candidate
|
|
533
|
+
session_base.mkdir(parents=True, exist_ok=True)
|
|
534
|
+
|
|
535
|
+
# Validate target_dir stays within the session's base dir
|
|
536
|
+
if not _is_safe_target(session_base, target_dir):
|
|
537
|
+
await ws.send_json({
|
|
538
|
+
"t": "SESSION_ABORT",
|
|
539
|
+
"code": "bad_target_dir",
|
|
540
|
+
"reason": "target_dir escapes base directory",
|
|
541
|
+
})
|
|
542
|
+
await ws.close()
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
expires = datetime.now(timezone.utc) + timedelta(seconds=cfg.session_resume_ttl)
|
|
546
|
+
try:
|
|
547
|
+
session = await registry.create(
|
|
548
|
+
session_id, resume_token, leg_id, expires.isoformat(),
|
|
549
|
+
target_dir=target_dir,
|
|
550
|
+
base_dir=session_base if session_base is not registry.base_dir else None,
|
|
551
|
+
meta_json=meta_json,
|
|
552
|
+
)
|
|
553
|
+
except ValueError:
|
|
554
|
+
# Session already exists — treat as conflict
|
|
555
|
+
await ws.send_json({
|
|
556
|
+
"t": "SESSION_ABORT",
|
|
557
|
+
"code": "conflict",
|
|
558
|
+
"reason": "session already exists",
|
|
559
|
+
})
|
|
560
|
+
await ws.close()
|
|
561
|
+
return
|
|
562
|
+
|
|
563
|
+
# Register in Redis index for TTL-based cleanup (with
|
|
564
|
+
# paths). A Redis failure here must not leave an orphaned
|
|
565
|
+
# in-memory session behind (a retried HELLO would then hit
|
|
566
|
+
# "conflict"), so roll back and abort explicitly.
|
|
567
|
+
sd = staging_dir(session_base, session_id, cfg.staging_prefix)
|
|
568
|
+
try:
|
|
569
|
+
idx = engine._require_index()
|
|
570
|
+
# Absolute staging path in Redis meta is what makes
|
|
571
|
+
# sweeper / lazy-resume work for per-user base dirs.
|
|
572
|
+
await idx.register(session_id, expires, target_dir, str(sd))
|
|
573
|
+
session._on_expiry_change = lambda sid, exp: idx.update_expiry(sid, exp)
|
|
574
|
+
except Exception:
|
|
575
|
+
logger.exception("Redis register failed for session %s — rolling back", session_id)
|
|
576
|
+
await registry.remove(session_id)
|
|
577
|
+
# Without a Redis entry the sweeper would never find
|
|
578
|
+
# this staging dir — remove it now.
|
|
579
|
+
shutil.rmtree(str(sd), ignore_errors=True)
|
|
580
|
+
session = None
|
|
581
|
+
await ws.send_json({
|
|
582
|
+
"t": "SESSION_ABORT",
|
|
583
|
+
"code": "server_error",
|
|
584
|
+
"reason": "session index unavailable, retry later",
|
|
585
|
+
})
|
|
586
|
+
await ws.close()
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
# Apply authorize-hook constraints to the live session
|
|
590
|
+
# (persisted — they must survive restarts / lazy-resume).
|
|
591
|
+
if auth_result is not None:
|
|
592
|
+
session.apply_auth(
|
|
593
|
+
auth_result.max_total_bytes,
|
|
594
|
+
auth_result.max_files,
|
|
595
|
+
auth_result.context,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
session.ws = ws
|
|
599
|
+
await ws.send_json({
|
|
600
|
+
"t": "HELLO_OK",
|
|
601
|
+
"epoch": session.epoch,
|
|
602
|
+
"expires_at": session.expires_at,
|
|
603
|
+
# Server-issued bearer token for data/probe/publish/RESUME.
|
|
604
|
+
"resume_token": resume_token,
|
|
605
|
+
"limits": {
|
|
606
|
+
"max_chunk_bytes": cfg.max_chunk_bytes,
|
|
607
|
+
"max_open_files": cfg.max_open_files,
|
|
608
|
+
"max_pending_files": cfg.max_pending_files,
|
|
609
|
+
},
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
elif t == "RESUME":
|
|
613
|
+
session_id = msg["session_id"]
|
|
614
|
+
resume_token = msg["resume_token"]
|
|
615
|
+
leg_id = msg["leg_id"]
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
try:
|
|
619
|
+
session = await registry.resume(session_id, resume_token, leg_id)
|
|
620
|
+
except KeyError:
|
|
621
|
+
# Lazy recovery: the session is not in THIS
|
|
622
|
+
# process's memory (different worker after a
|
|
623
|
+
# deploy/failover), but its durable state may
|
|
624
|
+
# still exist — Redis meta points at the staging
|
|
625
|
+
# dir. Ownership transfers to whichever worker
|
|
626
|
+
# holds the WS.
|
|
627
|
+
meta = await engine._require_index().get_meta(session_id)
|
|
628
|
+
if not meta or not meta.staging_dir:
|
|
629
|
+
raise
|
|
630
|
+
recovered = await registry.recover_session(session_id, Path(meta.staging_dir))
|
|
631
|
+
if recovered is None:
|
|
632
|
+
raise
|
|
633
|
+
logger.info("Lazy-recovered session %s for RESUME", session_id)
|
|
634
|
+
session = await registry.resume(session_id, resume_token, leg_id)
|
|
635
|
+
except KeyError:
|
|
636
|
+
await ws.send_json({
|
|
637
|
+
"t": "SESSION_ABORT",
|
|
638
|
+
"code": "not_found",
|
|
639
|
+
"reason": "session not found or expired",
|
|
640
|
+
})
|
|
641
|
+
await ws.close()
|
|
642
|
+
return
|
|
643
|
+
except PermissionError:
|
|
644
|
+
await ws.send_json({
|
|
645
|
+
"t": "SESSION_ABORT",
|
|
646
|
+
"code": "auth_failed",
|
|
647
|
+
"reason": "invalid resume token",
|
|
648
|
+
})
|
|
649
|
+
await ws.close()
|
|
650
|
+
return
|
|
651
|
+
except ValueError as exc:
|
|
652
|
+
await ws.send_json({
|
|
653
|
+
"t": "SESSION_ABORT",
|
|
654
|
+
"code": "invalid_state",
|
|
655
|
+
"reason": str(exc),
|
|
656
|
+
})
|
|
657
|
+
await ws.close()
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
session.ws = ws
|
|
661
|
+
# Ensure expiry callback is wired for resumed sessions
|
|
662
|
+
idx = engine._require_index()
|
|
663
|
+
session._on_expiry_change = lambda sid, exp: idx.update_expiry(sid, exp)
|
|
664
|
+
resume_ok = session.build_resume_ok()
|
|
665
|
+
await ws.send_json(resume_ok)
|
|
666
|
+
|
|
667
|
+
else:
|
|
668
|
+
await ws.send_json({
|
|
669
|
+
"t": "SESSION_ABORT",
|
|
670
|
+
"code": "protocol_error",
|
|
671
|
+
"reason": f"expected HELLO or RESUME, got {t}",
|
|
672
|
+
})
|
|
673
|
+
await ws.close()
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
# Main control loop — CLIENT_ABORT / ACTION
|
|
677
|
+
while True:
|
|
678
|
+
msg = await ws.receive_json()
|
|
679
|
+
t = msg.get("t")
|
|
680
|
+
|
|
681
|
+
if t == "CLIENT_ABORT":
|
|
682
|
+
if session:
|
|
683
|
+
session.db.set_state(SessionState.ABORTED)
|
|
684
|
+
session.detach_leg()
|
|
685
|
+
break
|
|
686
|
+
|
|
687
|
+
if t == "ACTION":
|
|
688
|
+
action = msg.get("action")
|
|
689
|
+
if session and action in ("merge_overwrite", "cancel"):
|
|
690
|
+
session.publish_action = action
|
|
691
|
+
logger.info("Session %s: ACTION=%s", session.session_id, action)
|
|
692
|
+
if action == "cancel":
|
|
693
|
+
session.db.set_state(SessionState.ABORTED)
|
|
694
|
+
session.detach_leg()
|
|
695
|
+
break
|
|
696
|
+
|
|
697
|
+
except WebSocketDisconnect:
|
|
698
|
+
logger.info("Control WS disconnected for session %s (state=%s)",
|
|
699
|
+
session.session_id if session else "unknown",
|
|
700
|
+
session.state.value if session else "n/a")
|
|
701
|
+
except Exception:
|
|
702
|
+
logger.exception("Control WS error for session %s",
|
|
703
|
+
session.session_id if session else "unknown")
|
|
704
|
+
finally:
|
|
705
|
+
if session:
|
|
706
|
+
# If in COMMITTING state, attempt commit before detaching
|
|
707
|
+
if session.state == SessionState.COMMITTING:
|
|
708
|
+
await engine._try_commit(session)
|
|
709
|
+
if session.ws is ws:
|
|
710
|
+
session.ws = None
|
|
711
|
+
if session.leg_id and session.state not in (
|
|
712
|
+
SessionState.COMMITTED, SessionState.ABORTED,
|
|
713
|
+
):
|
|
714
|
+
session.detach_leg()
|
|
715
|
+
# Clean up aborted sessions immediately: staging + Redis
|
|
716
|
+
if session.state == SessionState.ABORTED:
|
|
717
|
+
sid = session.session_id
|
|
718
|
+
await registry.remove(sid)
|
|
719
|
+
idx = engine._require_index()
|
|
720
|
+
meta = await idx.get_meta(sid)
|
|
721
|
+
if meta and meta.staging_dir:
|
|
722
|
+
sd = Path(meta.staging_dir)
|
|
723
|
+
else:
|
|
724
|
+
# session.base_dir, not the global one: per-user homes.
|
|
725
|
+
sd = staging_dir(session.base_dir, sid, cfg.staging_prefix)
|
|
726
|
+
if sd.exists():
|
|
727
|
+
shutil.rmtree(str(sd), ignore_errors=True)
|
|
728
|
+
await idx.remove(sid)
|
|
729
|
+
logger.info("Cleaned up aborted session %s (staging=%s)", sid, sd)
|
|
730
|
+
|
|
731
|
+
# -------------------------------------------------------------------
|
|
732
|
+
# HTTP data endpoint: POST /mfup/data/{session_id}/{leg_id}
|
|
733
|
+
# -------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
@router.post("/mfup/data/{session_id}/{leg_id}")
|
|
736
|
+
async def data_endpoint(session_id: str, leg_id: str, request: Request, seq: int, final: int = 0, epoch: int = -1):
|
|
737
|
+
registry = engine._require_registry()
|
|
738
|
+
session = registry.get(session_id)
|
|
739
|
+
|
|
740
|
+
if session is None:
|
|
741
|
+
return JSONResponse(
|
|
742
|
+
{"error": "session not found"},
|
|
743
|
+
status_code=status.HTTP_410_GONE,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Verify data channel auth token
|
|
747
|
+
token = request.headers.get("x-mfup-token")
|
|
748
|
+
if not token or token != session.resume_token:
|
|
749
|
+
return JSONResponse(
|
|
750
|
+
{"error": "invalid token"},
|
|
751
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
if session.leg_id != leg_id:
|
|
755
|
+
return JSONResponse(
|
|
756
|
+
{"error": "stale leg", "expected": session.leg_id},
|
|
757
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
# Epoch is mandatory: an old client omitting ?epoch= must not
|
|
761
|
+
# silently bypass stale-POST fencing.
|
|
762
|
+
if epoch < 0:
|
|
763
|
+
return JSONResponse(
|
|
764
|
+
{"error": "epoch_required"},
|
|
765
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# Reject requests from stale epochs (old reconnects / retries)
|
|
769
|
+
if session.epoch != epoch:
|
|
770
|
+
logger.warning(
|
|
771
|
+
"Rejected stale epoch for session %s: got %d, current %d",
|
|
772
|
+
session_id, epoch, session.epoch,
|
|
773
|
+
)
|
|
774
|
+
return JSONResponse(
|
|
775
|
+
{"error": "stale_epoch", "got": epoch, "expected": session.epoch},
|
|
776
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
state = session.state
|
|
780
|
+
if state not in (SessionState.ACTIVE, SessionState.PAUSED_BY_SERVER):
|
|
781
|
+
return JSONResponse(
|
|
782
|
+
{"error": f"session in state {state.value}"},
|
|
783
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
# Reject POSTs after final=1 has already been received for this leg
|
|
787
|
+
if session.final_seq_seen:
|
|
788
|
+
logger.warning(
|
|
789
|
+
"Rejected POST after final for session %s leg %s seq=%d",
|
|
790
|
+
session_id, leg_id, seq,
|
|
791
|
+
)
|
|
792
|
+
return JSONResponse(
|
|
793
|
+
{"error": "data_after_final", "detail": "final POST already received"},
|
|
794
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# Validate sequence number. Advancing is deferred: for buffered
|
|
798
|
+
# (batch) POSTs seq only advances after the body was FULLY
|
|
799
|
+
# processed, so a client may retry a failed POST with the same
|
|
800
|
+
# seq. A duplicate of an already processed POST then gets
|
|
801
|
+
# seq_mismatch with expected == seq + 1, which the client
|
|
802
|
+
# interprets as "already delivered".
|
|
803
|
+
if not session.validate_seq(seq):
|
|
804
|
+
logger.warning(
|
|
805
|
+
"Seq gap/duplicate for session %s leg %s: got seq=%d, expected=%d",
|
|
806
|
+
session_id, leg_id, seq, session.last_data_seq + 1,
|
|
807
|
+
)
|
|
808
|
+
return JSONResponse(
|
|
809
|
+
{"error": "seq_mismatch", "got": seq, "expected": session.last_data_seq + 1},
|
|
810
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
# Two body-handling strategies:
|
|
814
|
+
# - Buffered (atomic): Content-Length known and small enough —
|
|
815
|
+
# read the whole body first, process after. Either the entire
|
|
816
|
+
# POST is applied (and seq advances) or none of it is.
|
|
817
|
+
# - Streaming: chunked/oversized body (the duplex:"half" long
|
|
818
|
+
# POST). Frames apply as they arrive; a broken stream recovers
|
|
819
|
+
# through RESUME (new leg, new epoch), never retried by seq.
|
|
820
|
+
content_length = request.headers.get("content-length")
|
|
821
|
+
buffered = content_length is not None and int(content_length) <= cfg.max_buffered_body
|
|
822
|
+
|
|
823
|
+
reader = FrameReader()
|
|
824
|
+
body_received = 0
|
|
825
|
+
frame_count = 0
|
|
826
|
+
session_end_seen = False
|
|
827
|
+
error_detail: str | None = None
|
|
828
|
+
|
|
829
|
+
if buffered:
|
|
830
|
+
body = bytearray()
|
|
831
|
+
try:
|
|
832
|
+
async for chunk in request.stream():
|
|
833
|
+
body.extend(chunk)
|
|
834
|
+
if len(body) > cfg.max_buffered_body:
|
|
835
|
+
return JSONResponse(
|
|
836
|
+
{"error": "body_too_large"},
|
|
837
|
+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
838
|
+
)
|
|
839
|
+
except Exception as exc:
|
|
840
|
+
# Body never fully arrived — nothing was applied, seq not
|
|
841
|
+
# advanced, the client may retry this POST verbatim.
|
|
842
|
+
logger.warning(
|
|
843
|
+
"Buffered body read failed for session %s leg %s seq=%d: %s",
|
|
844
|
+
session_id, leg_id, seq, exc,
|
|
845
|
+
)
|
|
846
|
+
return JSONResponse(
|
|
847
|
+
{"error": "body_read_failed"},
|
|
848
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
# Re-check freshness after the (awaited) body read.
|
|
852
|
+
if session.leg_id != leg_id or session.epoch != epoch:
|
|
853
|
+
return JSONResponse(
|
|
854
|
+
{"error": "stale_epoch", "got": epoch, "expected": session.epoch},
|
|
855
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
session.db.begin_batch()
|
|
859
|
+
try:
|
|
860
|
+
reader.feed(bytes(body))
|
|
861
|
+
frames = reader.drain()
|
|
862
|
+
for frame in frames:
|
|
863
|
+
await session.process_frame(frame, leg_id)
|
|
864
|
+
frame_count += 1
|
|
865
|
+
if isinstance(frame, SessionEndFrame):
|
|
866
|
+
session_end_seen = True
|
|
867
|
+
body_received = len(body)
|
|
868
|
+
except Exception as exc:
|
|
869
|
+
error_detail = f"{type(exc).__name__}: {exc}"
|
|
870
|
+
logger.exception("Frame processing error for session %s leg %s seq=%d", session_id, leg_id, seq)
|
|
871
|
+
finally:
|
|
872
|
+
session.db.end_batch()
|
|
873
|
+
|
|
874
|
+
if error_detail is None:
|
|
875
|
+
session.advance_seq(seq)
|
|
876
|
+
if final == 1:
|
|
877
|
+
session.final_seq_seen = True
|
|
878
|
+
else:
|
|
879
|
+
# Streaming path: advance immediately (no retry-by-seq here).
|
|
880
|
+
session.advance_seq(seq)
|
|
881
|
+
if final == 1:
|
|
882
|
+
session.final_seq_seen = True
|
|
883
|
+
|
|
884
|
+
session.db.begin_batch()
|
|
885
|
+
frames_since_flush = 0
|
|
886
|
+
try:
|
|
887
|
+
async for chunk in request.stream():
|
|
888
|
+
if session.leg_id != leg_id or session.epoch != epoch:
|
|
889
|
+
logger.warning("Data stream for stale leg/epoch %s seq=%d, aborting read", leg_id, seq)
|
|
890
|
+
break
|
|
891
|
+
|
|
892
|
+
reader.feed(chunk)
|
|
893
|
+
frames = reader.drain()
|
|
894
|
+
for frame in frames:
|
|
895
|
+
await session.process_frame(frame, leg_id)
|
|
896
|
+
frame_count += 1
|
|
897
|
+
frames_since_flush += 1
|
|
898
|
+
if isinstance(frame, SessionEndFrame):
|
|
899
|
+
session_end_seen = True
|
|
900
|
+
if frames_since_flush >= 500:
|
|
901
|
+
session.db.flush()
|
|
902
|
+
frames_since_flush = 0
|
|
903
|
+
body_received += len(chunk)
|
|
904
|
+
|
|
905
|
+
except Exception as exc:
|
|
906
|
+
error_detail = f"{type(exc).__name__}: {exc}"
|
|
907
|
+
logger.exception("Data stream error for session %s leg %s seq=%d", session_id, leg_id, seq)
|
|
908
|
+
finally:
|
|
909
|
+
session.db.end_batch()
|
|
910
|
+
|
|
911
|
+
logger.info(
|
|
912
|
+
"Data stream ended for session %s leg %s seq=%d: %d bytes, %d frames, final=%d%s",
|
|
913
|
+
session_id, leg_id, seq, body_received, frame_count, final,
|
|
914
|
+
f", error: {error_detail}" if error_detail else "",
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
# Only attempt commit when final=1 or SESSION_END was in frames
|
|
918
|
+
commit_result = None
|
|
919
|
+
if (final == 1 or session_end_seen) and session.state == SessionState.COMMITTING:
|
|
920
|
+
commit_result = await engine._try_commit(session)
|
|
921
|
+
|
|
922
|
+
if error_detail:
|
|
923
|
+
return JSONResponse(
|
|
924
|
+
{"error": "data_stream_error", "detail": error_detail, "bytes_received": body_received},
|
|
925
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
resp: dict = {"ok": True, "bytes_received": body_received, "frames": frame_count}
|
|
929
|
+
if commit_result is not None:
|
|
930
|
+
resp["commit"] = {"files": commit_result["files"], "bytes": commit_result["bytes"]}
|
|
931
|
+
return JSONResponse(resp, status_code=status.HTTP_200_OK)
|
|
932
|
+
|
|
933
|
+
# -------------------------------------------------------------------
|
|
934
|
+
# HTTP probe endpoint: POST /mfup/probe/{session_id}
|
|
935
|
+
# -------------------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
@router.post("/mfup/probe/{session_id}")
|
|
938
|
+
async def probe_endpoint(session_id: str, request: Request):
|
|
939
|
+
registry = engine._require_registry()
|
|
940
|
+
session = registry.get(session_id)
|
|
941
|
+
|
|
942
|
+
if session is None:
|
|
943
|
+
return JSONResponse(
|
|
944
|
+
{"error": "session not found"},
|
|
945
|
+
status_code=status.HTTP_410_GONE,
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
token = request.headers.get("x-mfup-token")
|
|
949
|
+
if not token or token != session.resume_token:
|
|
950
|
+
return JSONResponse(
|
|
951
|
+
{"error": "invalid token"},
|
|
952
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
state = session.state
|
|
956
|
+
if state in (SessionState.COMMITTED, SessionState.ABORTED, SessionState.EXPIRED):
|
|
957
|
+
return JSONResponse(
|
|
958
|
+
{"error": f"session in terminal state {state.value}"},
|
|
959
|
+
status_code=status.HTTP_410_GONE,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
# Send PROBE_ACK after the first chunk arrives, with its size so
|
|
963
|
+
# the client can verify real binary data arrived (Firefox
|
|
964
|
+
# stringifies ReadableStream to 23 bytes of "[object ...]").
|
|
965
|
+
probe_ack_sent = False
|
|
966
|
+
total_bytes = 0
|
|
967
|
+
async for chunk in request.stream():
|
|
968
|
+
total_bytes += len(chunk)
|
|
969
|
+
if not probe_ack_sent:
|
|
970
|
+
if session.ws:
|
|
971
|
+
try:
|
|
972
|
+
await session.ws.send_json({
|
|
973
|
+
"t": "PROBE_ACK",
|
|
974
|
+
"first_chunk_bytes": len(chunk),
|
|
975
|
+
})
|
|
976
|
+
except Exception as exc:
|
|
977
|
+
logger.error("Failed to send PROBE_ACK for session %s: %s", session_id, exc)
|
|
978
|
+
probe_ack_sent = True
|
|
979
|
+
|
|
980
|
+
return JSONResponse(
|
|
981
|
+
{"ok": True, "total_bytes": total_bytes},
|
|
982
|
+
status_code=status.HTTP_200_OK,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
# -------------------------------------------------------------------
|
|
986
|
+
# Admin / status endpoints
|
|
987
|
+
# -------------------------------------------------------------------
|
|
988
|
+
|
|
989
|
+
def _admin_denied(request: Request) -> JSONResponse | None:
|
|
990
|
+
"""Admin routes require the token to be configured AND presented."""
|
|
991
|
+
if not cfg.admin_token:
|
|
992
|
+
return JSONResponse({"error": "admin routes disabled"}, status_code=status.HTTP_403_FORBIDDEN)
|
|
993
|
+
if request.headers.get("x-mfup-admin-token") != cfg.admin_token:
|
|
994
|
+
return JSONResponse({"error": "forbidden"}, status_code=status.HTTP_403_FORBIDDEN)
|
|
995
|
+
return None
|
|
996
|
+
|
|
997
|
+
@router.get("/mfup/sessions")
|
|
998
|
+
async def list_sessions(request: Request):
|
|
999
|
+
"""List all active sessions (admin/debug)."""
|
|
1000
|
+
denied = _admin_denied(request)
|
|
1001
|
+
if denied:
|
|
1002
|
+
return denied
|
|
1003
|
+
registry = engine._require_registry()
|
|
1004
|
+
result = []
|
|
1005
|
+
for sid, session in registry.all_sessions().items():
|
|
1006
|
+
result.append({
|
|
1007
|
+
"session_id": sid,
|
|
1008
|
+
"state": session.state.value,
|
|
1009
|
+
"epoch": session.epoch,
|
|
1010
|
+
"leg_id": session.leg_id,
|
|
1011
|
+
"expires_at": session.expires_at,
|
|
1012
|
+
})
|
|
1013
|
+
return result
|
|
1014
|
+
|
|
1015
|
+
@router.get("/mfup/sessions/{session_id}")
|
|
1016
|
+
async def get_session_status(session_id: str, request: Request):
|
|
1017
|
+
"""Get detailed status for a single session."""
|
|
1018
|
+
denied = _admin_denied(request)
|
|
1019
|
+
if denied:
|
|
1020
|
+
return denied
|
|
1021
|
+
registry = engine._require_registry()
|
|
1022
|
+
session = registry.get(session_id)
|
|
1023
|
+
if session is None:
|
|
1024
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
1025
|
+
|
|
1026
|
+
summary = session.db.get_root_summary()
|
|
1027
|
+
return {
|
|
1028
|
+
"session_id": session_id,
|
|
1029
|
+
"state": session.state.value,
|
|
1030
|
+
"epoch": session.epoch,
|
|
1031
|
+
"leg_id": session.leg_id,
|
|
1032
|
+
"expires_at": session.expires_at,
|
|
1033
|
+
"root_summary": summary,
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
@router.post("/mfup/sessions/{session_id}/publish")
|
|
1037
|
+
async def publish_endpoint(session_id: str, request: Request):
|
|
1038
|
+
"""Publish a committed session — atomic renames into target_dir."""
|
|
1039
|
+
registry = engine._require_registry()
|
|
1040
|
+
session = registry.get(session_id)
|
|
1041
|
+
if session is None:
|
|
1042
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
1043
|
+
|
|
1044
|
+
# Publish moves files and destroys staging — same bearer auth as
|
|
1045
|
+
# the data plane: the session's resume token.
|
|
1046
|
+
token = request.headers.get("x-mfup-token")
|
|
1047
|
+
if not token or token != session.resume_token:
|
|
1048
|
+
return JSONResponse({"error": "invalid token"}, status_code=status.HTTP_403_FORBIDDEN)
|
|
1049
|
+
|
|
1050
|
+
try:
|
|
1051
|
+
published = await engine.publish(session_id)
|
|
1052
|
+
except SessionNotFound:
|
|
1053
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
1054
|
+
except NotCommitted as exc:
|
|
1055
|
+
return JSONResponse(
|
|
1056
|
+
{"error": f"cannot publish session in state {exc.state}"},
|
|
1057
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
1058
|
+
)
|
|
1059
|
+
except TargetEscapes:
|
|
1060
|
+
return JSONResponse(
|
|
1061
|
+
{"error": "target_dir escapes base directory"},
|
|
1062
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
1063
|
+
)
|
|
1064
|
+
except MapFileHookError as exc:
|
|
1065
|
+
return JSONResponse(
|
|
1066
|
+
{"error": "map_file_hook_error", "path": exc.path},
|
|
1067
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1068
|
+
)
|
|
1069
|
+
except MappingError as exc:
|
|
1070
|
+
# Consumer-hook bug (escape / duplicate destination) — nothing
|
|
1071
|
+
# was moved.
|
|
1072
|
+
logger.error("Mapping error for session %s: %s", session_id, exc)
|
|
1073
|
+
return JSONResponse(
|
|
1074
|
+
{"error": "mapping_error", "detail": str(exc)},
|
|
1075
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
1076
|
+
)
|
|
1077
|
+
except ConflictError as exc:
|
|
1078
|
+
return JSONResponse(
|
|
1079
|
+
{"error": "conflict_files", "conflicting_files": exc.conflicting_files},
|
|
1080
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
1081
|
+
)
|
|
1082
|
+
except FileNotFoundError as exc:
|
|
1083
|
+
return JSONResponse({"error": str(exc)}, status_code=404)
|
|
1084
|
+
|
|
1085
|
+
return {"published": published}
|
|
1086
|
+
|
|
1087
|
+
@router.post("/mfup/sweep")
|
|
1088
|
+
async def sweep_endpoint(request: Request):
|
|
1089
|
+
"""Manually trigger a sweep — queries Redis, no iterdir."""
|
|
1090
|
+
denied = _admin_denied(request)
|
|
1091
|
+
if denied:
|
|
1092
|
+
return denied
|
|
1093
|
+
registry = engine._require_registry()
|
|
1094
|
+
idx = engine._require_index()
|
|
1095
|
+
expired_ids = await idx.get_expired()
|
|
1096
|
+
removed = []
|
|
1097
|
+
for sid in expired_ids:
|
|
1098
|
+
await registry.remove(sid)
|
|
1099
|
+
meta = await idx.get_meta(sid)
|
|
1100
|
+
if meta and meta.staging_dir:
|
|
1101
|
+
sd = Path(meta.staging_dir)
|
|
1102
|
+
else:
|
|
1103
|
+
sd = staging_dir(registry.base_dir, sid, cfg.staging_prefix)
|
|
1104
|
+
if sd.exists():
|
|
1105
|
+
shutil.rmtree(str(sd), ignore_errors=True)
|
|
1106
|
+
await idx.remove(sid)
|
|
1107
|
+
removed.append(sid)
|
|
1108
|
+
return {"removed": removed}
|
|
1109
|
+
|
|
1110
|
+
# -------------------------------------------------------------------
|
|
1111
|
+
# Health check
|
|
1112
|
+
# -------------------------------------------------------------------
|
|
1113
|
+
|
|
1114
|
+
@router.get("/health")
|
|
1115
|
+
async def health():
|
|
1116
|
+
return {"status": "ok", "protocol": PROTOCOL_VERSION, "crc32c": CRC32C_IMPL}
|
|
1117
|
+
|
|
1118
|
+
return router
|