mfup-core 0.2.0__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ node_modules/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .vite/
6
+ uploads/
7
+ repomix-output.md
8
+ *.url
9
+ repomix.sh
10
+ # e2e artifacts
11
+ e2e/.tmp/
12
+ e2e/test-results/
13
+ e2e/playwright-report/
14
+ e2e/webtop/testdata/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 n0isy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: mfup-core
3
+ Version: 0.2.0
4
+ Summary: MFUP/2 resumable multi-file upload engine: protocol codec, session state machine, SQLite staging, Redis expiry index, publish/mapping. Framework-free core.
5
+ Project-URL: Homepage, https://github.com/n0isy/mfup
6
+ Project-URL: Repository, https://github.com/n0isy/mfup
7
+ Project-URL: Issues, https://github.com/n0isy/mfup/issues
8
+ Author: n0isy
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: mfup,multi-file,protocol,resumable,upload
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: crc32c>=2.7
22
+ Requires-Dist: redis>=5.0.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # mfup-core
26
+
27
+ The MFUP/2 resumable multi-file upload **engine** — framework-free.
28
+
29
+ MFUP/2 moves whole directory trees (think `node_modules`-scale: tens of
30
+ thousands of small files) from a browser to a server over one WebSocket
31
+ control channel (JSON) plus HTTP data legs (binary frames), with:
32
+
33
+ - **resume** across page reloads, network drops and server restarts
34
+ (per-session SQLite journal in a staging directory, epoch/leg fencing);
35
+ - **interactive transfers** — the server can ASK the user mid-flight
36
+ (overwrite? cancel?) without stopping the stream;
37
+ - **integrity** — CRC-32C per chunk (C-accelerated, hard dependency),
38
+ commit invariants that catch lost metadata;
39
+ - **atomic publish** — staged files move into the target directory with
40
+ `rename()`, optionally re-laid-out per file by a consumer hook;
41
+ - **retention** — Redis expiry index plus a filesystem reconciliation
42
+ safety net; failed/cancelled sessions leave no garbage.
43
+
44
+ This package contains the protocol codec, the session state machine,
45
+ storage/publish, the Redis index, and the consumer hook contracts
46
+ (`AuthRequest/AuthResult`, `FileMapRequest`, `CommitEvent`). It does **not**
47
+ speak HTTP: pair it with [`mfup-fastapi`](https://pypi.org/project/mfup-fastapi/)
48
+ (or write your own transport shell against these primitives).
49
+
50
+ The browser side lives on npm: `@mfup/client` (TypeScript SDK) and
51
+ `@mfup/react` (hooks).
52
+
53
+ Docs and source: <https://github.com/n0isy/mfup> — see `docs/EXTENDING.md`
54
+ for the integration contract and `docs/FULL.md` for the protocol.
@@ -0,0 +1,30 @@
1
+ # mfup-core
2
+
3
+ The MFUP/2 resumable multi-file upload **engine** — framework-free.
4
+
5
+ MFUP/2 moves whole directory trees (think `node_modules`-scale: tens of
6
+ thousands of small files) from a browser to a server over one WebSocket
7
+ control channel (JSON) plus HTTP data legs (binary frames), with:
8
+
9
+ - **resume** across page reloads, network drops and server restarts
10
+ (per-session SQLite journal in a staging directory, epoch/leg fencing);
11
+ - **interactive transfers** — the server can ASK the user mid-flight
12
+ (overwrite? cancel?) without stopping the stream;
13
+ - **integrity** — CRC-32C per chunk (C-accelerated, hard dependency),
14
+ commit invariants that catch lost metadata;
15
+ - **atomic publish** — staged files move into the target directory with
16
+ `rename()`, optionally re-laid-out per file by a consumer hook;
17
+ - **retention** — Redis expiry index plus a filesystem reconciliation
18
+ safety net; failed/cancelled sessions leave no garbage.
19
+
20
+ This package contains the protocol codec, the session state machine,
21
+ storage/publish, the Redis index, and the consumer hook contracts
22
+ (`AuthRequest/AuthResult`, `FileMapRequest`, `CommitEvent`). It does **not**
23
+ speak HTTP: pair it with [`mfup-fastapi`](https://pypi.org/project/mfup-fastapi/)
24
+ (or write your own transport shell against these primitives).
25
+
26
+ The browser side lives on npm: `@mfup/client` (TypeScript SDK) and
27
+ `@mfup/react` (hooks).
28
+
29
+ Docs and source: <https://github.com/n0isy/mfup> — see `docs/EXTENDING.md`
30
+ for the integration contract and `docs/FULL.md` for the protocol.
@@ -0,0 +1,66 @@
1
+ """mfup-core — the MFUP/2 upload engine, framework-free.
2
+
3
+ Protocol codec, per-session SQLite staging, the session state machine,
4
+ Redis expiry index, publish/mapping, and the consumer hook contracts.
5
+ HTTP/WebSocket wiring lives in the companion package ``mfup-fastapi``.
6
+ """
7
+
8
+ from .protocol import (
9
+ CRC32C_IMPL,
10
+ PROTOCOL_VERSION,
11
+ FrameReader,
12
+ SessionState,
13
+ )
14
+ from .hooks import (
15
+ AuthRequest,
16
+ AuthResult,
17
+ AuthorizeHook,
18
+ CommitEvent,
19
+ FileMapRequest,
20
+ MapFileHook,
21
+ OnCommittedHook,
22
+ load_authorize_hook,
23
+ load_hook,
24
+ load_map_file_hook,
25
+ load_on_committed_hook,
26
+ resolve_hook,
27
+ )
28
+ from .session_manager import LiveSession, SessionRegistry
29
+ from .redis_index import SessionIndex
30
+ from .publish import (
31
+ ConflictError,
32
+ MappingError,
33
+ list_payload_files,
34
+ publish_session,
35
+ publish_session_mapped,
36
+ )
37
+ from .storage import staging_dir, validate_node_name
38
+
39
+ __all__ = [
40
+ "AuthRequest",
41
+ "AuthResult",
42
+ "AuthorizeHook",
43
+ "CommitEvent",
44
+ "ConflictError",
45
+ "CRC32C_IMPL",
46
+ "FileMapRequest",
47
+ "FrameReader",
48
+ "list_payload_files",
49
+ "LiveSession",
50
+ "load_authorize_hook",
51
+ "load_hook",
52
+ "load_map_file_hook",
53
+ "load_on_committed_hook",
54
+ "MapFileHook",
55
+ "MappingError",
56
+ "OnCommittedHook",
57
+ "PROTOCOL_VERSION",
58
+ "publish_session",
59
+ "publish_session_mapped",
60
+ "resolve_hook",
61
+ "SessionIndex",
62
+ "SessionRegistry",
63
+ "SessionState",
64
+ "staging_dir",
65
+ "validate_node_name",
66
+ ]
@@ -0,0 +1,203 @@
1
+ """MFUP/2 extension hooks — config-driven, no library packaging required.
2
+
3
+ A consumer plugs their code in via environment variables holding dotted
4
+ paths, e.g.:
5
+
6
+ MFUP_AUTHORIZE=myapp.uploads:authorize
7
+
8
+ The referenced module just has to be importable (on PYTHONPATH / mounted
9
+ into the container). The full contract lives in docs/EXTENDING.md.
10
+
11
+ Authorize contract
12
+ ------------------
13
+
14
+ async def authorize(req: AuthRequest) -> AuthResult | None:
15
+ ...
16
+
17
+ Called once per HELLO, before the session is created. Return:
18
+ - ``AuthResult(...)`` — allow, optionally constraining the session;
19
+ - ``None`` — deny → the client gets SESSION_ABORT(auth_failed).
20
+
21
+ Raising is treated as a deny (logged server-side, generic reason sent).
22
+
23
+ If ``MFUP_AUTHORIZE`` is unset the server runs **allow-all** and logs a
24
+ warning at startup — acceptable for development, not for production.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import importlib
30
+ import logging
31
+ from dataclasses import dataclass, field
32
+ from typing import Any, Awaitable, Callable, Mapping, Optional
33
+
34
+ logger = logging.getLogger("mfup.hooks")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class AuthRequest:
39
+ """Everything the server knows about an upload attempt at HELLO time."""
40
+
41
+ session_id: str
42
+ target_dir: str
43
+ #: HTTP headers of the WebSocket handshake (cookies, Authorization, …).
44
+ headers: Mapping[str, str]
45
+ #: Client address as reported by the ASGI server ("ip:port" or "").
46
+ client: str
47
+ #: Query parameters of the WebSocket URL.
48
+ query: Mapping[str, str]
49
+ #: Arbitrary JSON the CONSUMER'S FRONTEND attached to the session
50
+ #: (MfupSessionConfig.meta → HELLO.meta). Untrusted client input — the
51
+ #: hook validates it. Typical use: upload scope/purpose ("avatars",
52
+ #: {"album_id": 123}) that authorization and per-file mapping key on.
53
+ meta: Any = None
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class AuthResult:
58
+ """Permission plus per-session constraints. All limits optional."""
59
+
60
+ #: Cap on total accepted payload bytes; exceeding aborts the session
61
+ #: with SESSION_ABORT(quota_exceeded). None = unlimited.
62
+ max_total_bytes: Optional[int] = None
63
+ #: Cap on the number of file nodes; exceeding aborts likewise.
64
+ max_files: Optional[int] = None
65
+ #: Per-session BASE directory (absolute path) — e.g. the user's home.
66
+ #: Overrides MFUP_BASE_DIR for this session: the staging dir is created
67
+ #: inside it (publish stays a same-filesystem rename even when homes live
68
+ #: on their own mount), relative target_dir resolves against it, and the
69
+ #: containment check confines the session to it. Created if missing.
70
+ #: None = the global MFUP_BASE_DIR.
71
+ base_dir: Optional[str] = None
72
+ #: Optional override of the client-requested target_dir (e.g. force
73
+ #: uploads into a fixed subdirectory, or a MAPPING of the client's
74
+ #: request — the hook receives req.target_dir and may prefix/rewrite it:
75
+ #: target_dir=f"incoming/{req.target_dir}"
76
+ #: Escapes are impossible regardless: the resolved target must stay
77
+ #: within the session's base_dir or HELLO is refused (bad_target_dir).
78
+ target_dir: Optional[str] = None
79
+ #: Free-form bag the consumer may use to correlate sessions with users;
80
+ #: stored in memory on the LiveSession, never persisted or sent to the
81
+ #: client.
82
+ context: dict[str, Any] = field(default_factory=dict)
83
+
84
+
85
+ AuthorizeHook = Callable[[AuthRequest], Awaitable[Optional[AuthResult]]]
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class FileMapRequest:
90
+ """One file about to be published — input to the map_file hook."""
91
+
92
+ session_id: str
93
+ #: Path of the file inside the uploaded tree, "/"-separated, as the
94
+ #: client sent it (e.g. "photos/2024/img_001.jpg").
95
+ path: str
96
+ #: Basename convenience (last segment of `path`).
97
+ name: str
98
+ #: Actual size on disk, bytes.
99
+ size: int
100
+ #: The session's target_dir (already authorized/mapped at HELLO).
101
+ target_dir: str
102
+ #: Client-attached session meta (see AuthRequest.meta).
103
+ meta: Any
104
+ #: AuthResult.context from the authorize hook.
105
+ context: Mapping[str, Any]
106
+
107
+
108
+ #: async (FileMapRequest) -> str | None
109
+ #: str — new path RELATIVE to target_dir (e.g. "media/img_001.jpg");
110
+ #: None — keep the client's layout for this file.
111
+ #: Two files mapping to the same destination is a consumer bug → publish
112
+ #: fails with mapping_conflict. Escaping segments ("..", absolute, "\\")
113
+ #: fail publish likewise. The hook runs once per file at PUBLISH time, so
114
+ #: it does not need to be deterministic across retries of the transfer.
115
+ MapFileHook = Callable[[FileMapRequest], Awaitable[Optional[str]]]
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class CommitEvent:
120
+ """A session just committed — input to the on_committed hook."""
121
+
122
+ session_id: str
123
+ #: The session's (authorized/mapped) target_dir.
124
+ target_dir: str
125
+ #: The session's base directory (per-user home or the global base).
126
+ base_dir: str
127
+ #: Absolute staging directory holding the committed payload.
128
+ staging_dir: str
129
+ #: Committed file count / total payload bytes (as sent in COMMIT_OK).
130
+ files: int
131
+ bytes: int
132
+ #: Client-attached session meta (see AuthRequest.meta). Untrusted.
133
+ meta: Any
134
+ #: AuthResult.context from the authorize hook.
135
+ context: Mapping[str, Any]
136
+
137
+
138
+ #: async (CommitEvent) -> str | None
139
+ #: "publish" — the server publishes immediately (server-side decision:
140
+ #: scan passed, billing ok, …). The browser's own publish call,
141
+ #: if any, will find the session gone (404) — harmless.
142
+ #: None — do nothing; publish stays client-driven (or the consumer
143
+ #: backend calls MfupEngine.publish() later).
144
+ #: Raising is logged and treated as None — a broken consumer hook must not
145
+ #: strand committed sessions.
146
+ OnCommittedHook = Callable[[CommitEvent], Awaitable[Optional[str]]]
147
+
148
+
149
+ def load_hook(dotted: str) -> Callable[..., Any]:
150
+ """Import ``pkg.module:attr`` and return the attribute.
151
+
152
+ Raises ImportError/AttributeError loudly — a misconfigured hook must
153
+ fail at startup, not silently run allow-all.
154
+ """
155
+ module_path, sep, attr = dotted.partition(":")
156
+ if not sep or not module_path or not attr:
157
+ raise ImportError(
158
+ f"invalid hook path {dotted!r}: expected 'package.module:callable'"
159
+ )
160
+ module = importlib.import_module(module_path)
161
+ return getattr(module, attr)
162
+
163
+
164
+ def load_authorize_hook(dotted: str | None) -> AuthorizeHook | None:
165
+ """Resolve MFUP_AUTHORIZE. None (unset) → allow-all with a warning."""
166
+ if not dotted:
167
+ logger.warning(
168
+ "MFUP_AUTHORIZE is not set — running WITHOUT authorization "
169
+ "(allow-all). Do not do this in production."
170
+ )
171
+ return None
172
+ hook = load_hook(dotted)
173
+ logger.info("Authorize hook loaded: %s", dotted)
174
+ return hook # type: ignore[return-value]
175
+
176
+
177
+ def load_map_file_hook(dotted: str | None) -> MapFileHook | None:
178
+ """Resolve MFUP_MAP_FILE. None (unset) → identity layout."""
179
+ if not dotted:
180
+ return None
181
+ hook = load_hook(dotted)
182
+ logger.info("map_file hook loaded: %s", dotted)
183
+ return hook # type: ignore[return-value]
184
+
185
+
186
+ def load_on_committed_hook(dotted: str | None) -> OnCommittedHook | None:
187
+ """Resolve MFUP_ON_COMMITTED. None (unset) → client-driven publish."""
188
+ if not dotted:
189
+ return None
190
+ hook = load_hook(dotted)
191
+ logger.info("on_committed hook loaded: %s", dotted)
192
+ return hook # type: ignore[return-value]
193
+
194
+
195
+ def resolve_hook(ref: Callable[..., Any] | str | None) -> Callable[..., Any] | None:
196
+ """Accept a hook given either as the callable itself (library embedding:
197
+ ``MfupConfig(authorize=my_func)``) or as a dotted path (env-driven:
198
+ ``"pkg.module:callable"``). None passes through."""
199
+ if ref is None:
200
+ return None
201
+ if callable(ref):
202
+ return ref
203
+ return load_hook(ref)