fluxfiles-token 0.1.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.
- fluxfiles_token/__init__.py +38 -0
- fluxfiles_token/crypto.py +78 -0
- fluxfiles_token/exceptions.py +9 -0
- fluxfiles_token/py.typed +0 -0
- fluxfiles_token/token.py +841 -0
- fluxfiles_token/types.py +71 -0
- fluxfiles_token/verify.py +32 -0
- fluxfiles_token-0.1.0.dist-info/METADATA +226 -0
- fluxfiles_token-0.1.0.dist-info/RECORD +11 -0
- fluxfiles_token-0.1.0.dist-info/WHEEL +4 -0
- fluxfiles_token-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""fluxfiles-token: mint and verify FluxFiles JWTs from Python.
|
|
2
|
+
|
|
3
|
+
Byte-compatible with the PHP core (`packages/core/embed.php`,
|
|
4
|
+
`CredentialEncryptor.php`) and a 1:1 port of `@fluxfiles/node`
|
|
5
|
+
(camelCase -> snake_case). See docs/PYTHON-TOKEN-SDK-DESIGN.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .crypto import decrypt_byob, encrypt_byob
|
|
9
|
+
from .exceptions import FluxFilesByobError, FluxFilesTokenError
|
|
10
|
+
from .token import EDITION_PRESETS, ROLE_PRESETS, create_byob_token, create_token
|
|
11
|
+
from .types import (
|
|
12
|
+
ByobDiskConfig,
|
|
13
|
+
ByobS3DiskConfig,
|
|
14
|
+
ByobSftpDiskConfig,
|
|
15
|
+
FluxClaims,
|
|
16
|
+
FluxPermission,
|
|
17
|
+
)
|
|
18
|
+
from .verify import decode_token, verify_token
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"create_token",
|
|
22
|
+
"create_byob_token",
|
|
23
|
+
"verify_token",
|
|
24
|
+
"decode_token",
|
|
25
|
+
"encrypt_byob",
|
|
26
|
+
"decrypt_byob",
|
|
27
|
+
"ROLE_PRESETS",
|
|
28
|
+
"EDITION_PRESETS",
|
|
29
|
+
"FluxFilesTokenError",
|
|
30
|
+
"FluxFilesByobError",
|
|
31
|
+
"ByobDiskConfig",
|
|
32
|
+
"ByobS3DiskConfig",
|
|
33
|
+
"ByobSftpDiskConfig",
|
|
34
|
+
"FluxClaims",
|
|
35
|
+
"FluxPermission",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""BYOB credential encryption — matches PHP `CredentialEncryptor` and
|
|
2
|
+
`packages/node/src/crypto.ts` byte-for-byte.
|
|
3
|
+
|
|
4
|
+
AES-256-GCM. Key = HKDF-SHA256(ikm = secret, salt = 32 zero bytes,
|
|
5
|
+
info = "fluxfiles-byob-enc", len = 32). Blob = base64(nonce[12] | ct | tag[16]).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives import hashes
|
|
14
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
15
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
16
|
+
|
|
17
|
+
from .exceptions import FluxFilesByobError
|
|
18
|
+
|
|
19
|
+
_BYOB_INFO = b"fluxfiles-byob-enc"
|
|
20
|
+
_NONCE_LEN = 12
|
|
21
|
+
_TAG_LEN = 16
|
|
22
|
+
_KEY_LEN = 32
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def derive_byob_key(secret: str) -> bytes:
|
|
26
|
+
"""Derive the BYOB key exactly like PHP `hash_hkdf('sha256', $secret, 32, $info)`.
|
|
27
|
+
|
|
28
|
+
PHP's empty HKDF salt means "HashLen zero bytes" (RFC 5869), so we pass an
|
|
29
|
+
explicit 32-byte zero salt. `cryptography`'s HKDF already defaults an
|
|
30
|
+
omitted salt to the same RFC-5869-correct value, but we pin it explicitly
|
|
31
|
+
anyway so this stays self-documenting and immune to any future change in
|
|
32
|
+
that default — this single line is the crux of cross-language parity.
|
|
33
|
+
"""
|
|
34
|
+
hkdf = HKDF(algorithm=hashes.SHA256(), length=_KEY_LEN, salt=b"\x00" * 32, info=_BYOB_INFO)
|
|
35
|
+
return hkdf.derive(secret.encode("utf-8"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def encrypt_byob(config: dict[str, Any], secret: str) -> str:
|
|
39
|
+
"""Encrypt a disk config into a base64 blob: nonce(12) || ciphertext || tag(16)."""
|
|
40
|
+
key = derive_byob_key(secret)
|
|
41
|
+
nonce = os.urandom(_NONCE_LEN)
|
|
42
|
+
aesgcm = AESGCM(key)
|
|
43
|
+
# json.dumps never escapes "/", matching PHP's JSON_UNESCAPED_SLASHES.
|
|
44
|
+
plaintext = json.dumps(config, ensure_ascii=False).encode("utf-8")
|
|
45
|
+
# AESGCM.encrypt() returns ciphertext || tag already concatenated (tag is
|
|
46
|
+
# always the trailing 16 bytes) — no manual tag-splicing needed.
|
|
47
|
+
ct_and_tag = aesgcm.encrypt(nonce, plaintext, None)
|
|
48
|
+
return base64.b64encode(nonce + ct_and_tag).decode("ascii")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decrypt_byob(blob: str, secret: str) -> dict[str, Any]:
|
|
52
|
+
"""Decrypt a base64 blob back into a disk config dict. Raises
|
|
53
|
+
FluxFilesByobError on a malformed blob, wrong secret, or tampered data."""
|
|
54
|
+
key = derive_byob_key(secret)
|
|
55
|
+
try:
|
|
56
|
+
raw = base64.b64decode(blob, validate=True)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
raise FluxFilesByobError("FluxFiles: invalid BYOB credential blob") from exc
|
|
59
|
+
|
|
60
|
+
if len(raw) < _NONCE_LEN + _TAG_LEN + 1:
|
|
61
|
+
raise FluxFilesByobError("FluxFiles: invalid BYOB credential blob")
|
|
62
|
+
|
|
63
|
+
nonce = raw[:_NONCE_LEN]
|
|
64
|
+
ciphertext_and_tag = raw[_NONCE_LEN:]
|
|
65
|
+
aesgcm = AESGCM(key)
|
|
66
|
+
try:
|
|
67
|
+
# AESGCM.decrypt() expects ciphertext || tag concatenated — exactly the
|
|
68
|
+
# wire format above, so no re-slicing beyond splitting off the nonce.
|
|
69
|
+
plaintext = aesgcm.decrypt(nonce, ciphertext_and_tag, None)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise FluxFilesByobError(
|
|
72
|
+
"FluxFiles: BYOB credential decryption failed — token may be tampered"
|
|
73
|
+
) from exc
|
|
74
|
+
|
|
75
|
+
config = json.loads(plaintext.decode("utf-8"))
|
|
76
|
+
if not isinstance(config, dict):
|
|
77
|
+
raise FluxFilesByobError("FluxFiles: invalid BYOB credential format")
|
|
78
|
+
return config
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Errors raised by fluxfiles_token."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FluxFilesTokenError(Exception):
|
|
5
|
+
"""Base error for token construction/verification failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FluxFilesByobError(FluxFilesTokenError):
|
|
9
|
+
"""BYOB disk validation, or credential encryption/decryption, failure."""
|
fluxfiles_token/py.typed
ADDED
|
File without changes
|
fluxfiles_token/token.py
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"""Mint FluxFiles-compatible JWTs — plain (`create_token`) and BYOB
|
|
2
|
+
(`create_byob_token`). Mirrors `packages/node/src/token.ts` 1:1 (camelCase ->
|
|
3
|
+
snake_case) and PHP's `embed.php` (`_fluxfiles_build_token()` /
|
|
4
|
+
`fluxfiles_byob_token()`), including the role/edition preset mechanics from
|
|
5
|
+
docs/ACL-ROLE-PRESETS-DESIGN.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import jwt
|
|
15
|
+
|
|
16
|
+
from .crypto import encrypt_byob
|
|
17
|
+
from .exceptions import FluxFilesByobError, FluxFilesTokenError
|
|
18
|
+
from .types import ByobDiskConfig
|
|
19
|
+
|
|
20
|
+
_MIN_SECRET_BYTES = 32
|
|
21
|
+
|
|
22
|
+
# Role preset (DX sugar, docs/ACL-ROLE-PRESETS-DESIGN.md): defaults a person's
|
|
23
|
+
# capability level; explicit kwargs always win. `role` never itself becomes a
|
|
24
|
+
# JWT claim — it only ever expands into ordinary claims decoded server-side
|
|
25
|
+
# already. `viewer`/`editor` set `allow_extract`/`allow_chmod` explicitly
|
|
26
|
+
# (rather than omitting them) because `Claims::fromJwtPayload` defaults BOTH
|
|
27
|
+
# to `True` when absent — omitting them here would silently grant chmod/
|
|
28
|
+
# extract to a token meant to be read-only/contributor-only (the shipped
|
|
29
|
+
# `d880b98`/`fb7c8a2` bug, see CHANGELOG.md `[0.3.00]`).
|
|
30
|
+
ROLE_PRESETS: dict[str, dict[str, Any]] = {
|
|
31
|
+
"viewer": {
|
|
32
|
+
"perms": ["read"],
|
|
33
|
+
"owner_only": True,
|
|
34
|
+
"allow_extract": False,
|
|
35
|
+
"allow_chmod": False,
|
|
36
|
+
},
|
|
37
|
+
"editor": {
|
|
38
|
+
"perms": ["read", "write"],
|
|
39
|
+
"owner_only": True,
|
|
40
|
+
"allow_extract": True,
|
|
41
|
+
"allow_chmod": False,
|
|
42
|
+
},
|
|
43
|
+
"admin": {
|
|
44
|
+
"perms": ["read", "write", "delete", "audit"],
|
|
45
|
+
"owner_only": False,
|
|
46
|
+
"allow_extract": True,
|
|
47
|
+
"allow_chmod": True,
|
|
48
|
+
"allow_code_edit": True,
|
|
49
|
+
"show_hidden": True,
|
|
50
|
+
},
|
|
51
|
+
"superadmin": { # identical bundle to admin — see ACL doc §2's note
|
|
52
|
+
"perms": ["read", "write", "delete", "audit"],
|
|
53
|
+
"owner_only": False,
|
|
54
|
+
"allow_extract": True,
|
|
55
|
+
"allow_chmod": True,
|
|
56
|
+
"allow_code_edit": True,
|
|
57
|
+
"show_hidden": True,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Edition preset (DX sugar): defaults a tier's claims; explicit kwargs still
|
|
62
|
+
# win, and the license is still the real gate — a preset just defaults the
|
|
63
|
+
# per-tenant claims for a tier.
|
|
64
|
+
EDITION_PRESETS: dict[str, dict[str, bool]] = {
|
|
65
|
+
"pro": {"allow_optimize": True, "allow_share": True, "allow_intake": True},
|
|
66
|
+
"agency": {"allow_optimize": True, "allow_share": True, "allow_intake": True},
|
|
67
|
+
"studio": {
|
|
68
|
+
"allow_optimize": True,
|
|
69
|
+
"allow_share": True,
|
|
70
|
+
"allow_intake": True,
|
|
71
|
+
"allow_versioning": True,
|
|
72
|
+
"allow_webhooks": True,
|
|
73
|
+
"allow_ai_vision": True,
|
|
74
|
+
"allow_ocr": True,
|
|
75
|
+
},
|
|
76
|
+
"enterprise": {
|
|
77
|
+
"allow_optimize": True,
|
|
78
|
+
"allow_share": True,
|
|
79
|
+
"allow_intake": True,
|
|
80
|
+
"allow_versioning": True,
|
|
81
|
+
"allow_webhooks": True,
|
|
82
|
+
"allow_ai_vision": True,
|
|
83
|
+
"allow_ocr": True,
|
|
84
|
+
"allow_virus_scan": True,
|
|
85
|
+
"allow_c2pa": True,
|
|
86
|
+
"allow_backup": True,
|
|
87
|
+
"allow_audit_export": True,
|
|
88
|
+
"allow_dlp_scan": True,
|
|
89
|
+
"allow_legal_hold": True,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_secret(explicit: str | None) -> str:
|
|
95
|
+
secret = explicit if explicit is not None else os.environ.get("FLUXFILES_SECRET", "")
|
|
96
|
+
if len(secret.encode("utf-8")) < _MIN_SECRET_BYTES:
|
|
97
|
+
raise FluxFilesTokenError(
|
|
98
|
+
f"FluxFiles: signing secret must be at least {_MIN_SECRET_BYTES} bytes "
|
|
99
|
+
"(HS256 key requirement). Set `secret` or FLUXFILES_SECRET."
|
|
100
|
+
)
|
|
101
|
+
return secret
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sign(payload: dict[str, Any], secret: str) -> str:
|
|
105
|
+
return jwt.encode(payload, secret, algorithm="HS256")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _new_jti() -> str:
|
|
109
|
+
return secrets.token_hex(12)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _sanitize_variants(v: dict[str, int] | None) -> dict[str, int] | None:
|
|
113
|
+
"""Matches PHP `Claims::sanitizeVariants` / Node's `sanitizeVariants`."""
|
|
114
|
+
if not v or not isinstance(v, dict):
|
|
115
|
+
return None
|
|
116
|
+
out: dict[str, int] = {}
|
|
117
|
+
for name in ("thumb", "medium", "large"):
|
|
118
|
+
if name not in v:
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
width = int(v[name])
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
continue
|
|
124
|
+
if 16 <= width <= 8000:
|
|
125
|
+
out[name] = width
|
|
126
|
+
return out or None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _apply_tenant_overrides(
|
|
130
|
+
payload: dict[str, Any],
|
|
131
|
+
extras: dict[str, Any],
|
|
132
|
+
edition: str | None,
|
|
133
|
+
role_preset: dict[str, Any] | None,
|
|
134
|
+
claims: dict[str, Any] | None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Copy the optional per-tenant override claims into a payload when set.
|
|
137
|
+
Merge order (matches embed.php's `_fluxfiles_build_token()` and Node's
|
|
138
|
+
`applyTenantOverrides()` exactly): edition preset (fills absent keys only)
|
|
139
|
+
-> role preset, excluding perms/owner_only, already resolved by the caller
|
|
140
|
+
(fills absent keys only) -> every typed kwarg in `extras` (explicit,
|
|
141
|
+
always overwrites a preset default) -> `claims` escape hatch (always wins,
|
|
142
|
+
merged last)."""
|
|
143
|
+
edition_preset = EDITION_PRESETS.get(str(edition).lower()) if edition else None
|
|
144
|
+
if edition_preset:
|
|
145
|
+
for k, v in edition_preset.items():
|
|
146
|
+
if k not in payload:
|
|
147
|
+
payload[k] = v
|
|
148
|
+
# Role preset: the rest of the bundle, excluding `perms`/`owner_only` — both
|
|
149
|
+
# are already resolved by the caller before this function ever runs.
|
|
150
|
+
if role_preset:
|
|
151
|
+
for k, v in role_preset.items():
|
|
152
|
+
if k not in ("perms", "owner_only") and k not in payload:
|
|
153
|
+
payload[k] = v
|
|
154
|
+
|
|
155
|
+
if extras.get("ai_auto_tag") is not None:
|
|
156
|
+
payload["ai_auto_tag"] = bool(extras["ai_auto_tag"])
|
|
157
|
+
rate_read = extras.get("rate_read")
|
|
158
|
+
if rate_read and rate_read > 0:
|
|
159
|
+
payload["rate_read"] = int(rate_read)
|
|
160
|
+
rate_write = extras.get("rate_write")
|
|
161
|
+
if rate_write and rate_write > 0:
|
|
162
|
+
payload["rate_write"] = int(rate_write)
|
|
163
|
+
variants = _sanitize_variants(extras.get("variants"))
|
|
164
|
+
if variants:
|
|
165
|
+
payload["variants"] = variants
|
|
166
|
+
|
|
167
|
+
# URL-import claims (the server sanitizes/clamps these on decode).
|
|
168
|
+
if extras.get("allow_url_import"):
|
|
169
|
+
payload["allow_url_import"] = True
|
|
170
|
+
max_import_mb = extras.get("max_import_mb")
|
|
171
|
+
if max_import_mb and max_import_mb > 0:
|
|
172
|
+
payload["max_import_mb"] = int(max_import_mb)
|
|
173
|
+
import_rate_limit = extras.get("import_rate_limit")
|
|
174
|
+
if import_rate_limit and import_rate_limit > 0:
|
|
175
|
+
payload["import_rate_limit"] = int(import_rate_limit)
|
|
176
|
+
import_concurrency = extras.get("import_concurrency")
|
|
177
|
+
if import_concurrency and import_concurrency > 0:
|
|
178
|
+
payload["import_concurrency"] = int(import_concurrency)
|
|
179
|
+
import_path = extras.get("import_path")
|
|
180
|
+
if import_path:
|
|
181
|
+
payload["import_path"] = str(import_path)
|
|
182
|
+
import_url_allowlist = extras.get("import_url_allowlist")
|
|
183
|
+
if import_url_allowlist:
|
|
184
|
+
payload["import_url_allowlist"] = [str(h) for h in import_url_allowlist]
|
|
185
|
+
|
|
186
|
+
# Media-preview claims (the server sanitizes/clamps these on decode).
|
|
187
|
+
if extras.get("media_preview") is not None:
|
|
188
|
+
payload["media_preview"] = bool(extras["media_preview"])
|
|
189
|
+
preview_url_ttl = extras.get("preview_url_ttl")
|
|
190
|
+
if preview_url_ttl and preview_url_ttl > 0:
|
|
191
|
+
payload["preview_url_ttl"] = int(preview_url_ttl)
|
|
192
|
+
max_preview_mb = extras.get("max_preview_mb")
|
|
193
|
+
if max_preview_mb and max_preview_mb > 0:
|
|
194
|
+
payload["max_preview_mb"] = int(max_preview_mb)
|
|
195
|
+
stream_token_ttl = extras.get("stream_token_ttl")
|
|
196
|
+
if stream_token_ttl and stream_token_ttl > 0:
|
|
197
|
+
payload["stream_token_ttl"] = int(stream_token_ttl)
|
|
198
|
+
|
|
199
|
+
# On-demand WebP/AVIF claims.
|
|
200
|
+
if extras.get("webp_enabled") is not None:
|
|
201
|
+
payload["webp_enabled"] = bool(extras["webp_enabled"])
|
|
202
|
+
webp_max_width = extras.get("webp_max_width")
|
|
203
|
+
if webp_max_width and webp_max_width > 0:
|
|
204
|
+
payload["webp_max_width"] = int(webp_max_width)
|
|
205
|
+
webp_default_quality = extras.get("webp_default_quality")
|
|
206
|
+
if webp_default_quality and webp_default_quality > 0:
|
|
207
|
+
payload["webp_default_quality"] = int(webp_default_quality)
|
|
208
|
+
srcset_widths = extras.get("srcset_widths")
|
|
209
|
+
if srcset_widths is not None:
|
|
210
|
+
payload["srcset_widths"] = [int(w) for w in srcset_widths]
|
|
211
|
+
srcset_sizes = extras.get("srcset_sizes")
|
|
212
|
+
if srcset_sizes:
|
|
213
|
+
payload["srcset_sizes"] = str(srcset_sizes)
|
|
214
|
+
|
|
215
|
+
# Download gate / SFTP chmod / code-edit / terminal / BYO-embed URLs.
|
|
216
|
+
if extras.get("allow_download") is not None:
|
|
217
|
+
payload["allow_download"] = bool(extras["allow_download"])
|
|
218
|
+
if extras.get("allow_chmod") is not None:
|
|
219
|
+
payload["allow_chmod"] = bool(extras["allow_chmod"])
|
|
220
|
+
if extras.get("allow_code_edit") is not None:
|
|
221
|
+
payload["allow_code_edit"] = bool(extras["allow_code_edit"])
|
|
222
|
+
# SSH terminal (SFTP disks). Core-standalone — the token must target a real
|
|
223
|
+
# core (this SDK mints for one), not a proxy adapter that doesn't serve
|
|
224
|
+
# /api/fm/terminal.
|
|
225
|
+
if extras.get("allow_terminal") is not None:
|
|
226
|
+
payload["allow_terminal"] = bool(extras["allow_terminal"])
|
|
227
|
+
terminal_pty_url = extras.get("terminal_pty_url")
|
|
228
|
+
if terminal_pty_url:
|
|
229
|
+
payload["terminal_pty_url"] = str(terminal_pty_url)
|
|
230
|
+
pdf_tools_url = extras.get("pdf_tools_url")
|
|
231
|
+
if pdf_tools_url:
|
|
232
|
+
payload["pdf_tools_url"] = str(pdf_tools_url)
|
|
233
|
+
office_url = extras.get("office_url")
|
|
234
|
+
if office_url:
|
|
235
|
+
payload["office_url"] = str(office_url)
|
|
236
|
+
esign_url = extras.get("esign_url")
|
|
237
|
+
if esign_url:
|
|
238
|
+
payload["esign_url"] = str(esign_url)
|
|
239
|
+
|
|
240
|
+
# Optimization (free/core).
|
|
241
|
+
if extras.get("allow_optimize") is not None:
|
|
242
|
+
payload["allow_optimize"] = bool(extras["allow_optimize"])
|
|
243
|
+
if extras.get("auto_optimize") is not None:
|
|
244
|
+
payload["auto_optimize"] = bool(extras["auto_optimize"])
|
|
245
|
+
optimize_quality = extras.get("optimize_quality")
|
|
246
|
+
if optimize_quality and optimize_quality > 0:
|
|
247
|
+
payload["optimize_quality"] = int(optimize_quality)
|
|
248
|
+
if extras.get("optimize_keep_original") is not None:
|
|
249
|
+
payload["optimize_keep_original"] = bool(extras["optimize_keep_original"])
|
|
250
|
+
optimize_max_mb = extras.get("optimize_max_mb")
|
|
251
|
+
if optimize_max_mb and optimize_max_mb > 0:
|
|
252
|
+
payload["optimize_max_mb"] = int(optimize_max_mb)
|
|
253
|
+
pdf_level = extras.get("pdf_level")
|
|
254
|
+
if pdf_level in ("screen", "ebook", "printer", "prepress", "default"):
|
|
255
|
+
payload["pdf_level"] = pdf_level
|
|
256
|
+
|
|
257
|
+
# Upload behavior / listing.
|
|
258
|
+
upload_collision = extras.get("upload_collision")
|
|
259
|
+
if upload_collision in ("rename", "overwrite", "reject"):
|
|
260
|
+
payload["upload_collision"] = upload_collision
|
|
261
|
+
if extras.get("show_hidden") is not None:
|
|
262
|
+
payload["show_hidden"] = bool(extras["show_hidden"])
|
|
263
|
+
if extras.get("dedupe_uploads") is not None:
|
|
264
|
+
payload["dedupe_uploads"] = bool(extras["dedupe_uploads"])
|
|
265
|
+
|
|
266
|
+
# Zip/extract.
|
|
267
|
+
if extras.get("allow_zip") is not None:
|
|
268
|
+
payload["allow_zip"] = bool(extras["allow_zip"])
|
|
269
|
+
if extras.get("allow_extract") is not None:
|
|
270
|
+
payload["allow_extract"] = bool(extras["allow_extract"])
|
|
271
|
+
zip_max_mb = extras.get("zip_max_mb")
|
|
272
|
+
if zip_max_mb and zip_max_mb > 0:
|
|
273
|
+
payload["zip_max_mb"] = int(zip_max_mb)
|
|
274
|
+
zip_max_files = extras.get("zip_max_files")
|
|
275
|
+
if zip_max_files and zip_max_files > 0:
|
|
276
|
+
payload["zip_max_files"] = int(zip_max_files)
|
|
277
|
+
|
|
278
|
+
# Paid-module claims (all default off; inert unless installed + licensed).
|
|
279
|
+
if extras.get("allow_share") is not None:
|
|
280
|
+
payload["allow_share"] = bool(extras["allow_share"])
|
|
281
|
+
# Share landing config — read at create time and baked into the share
|
|
282
|
+
# record (the core clamps the TTL and drops a non-http(s) base URL on decode).
|
|
283
|
+
share_url_ttl = extras.get("share_url_ttl")
|
|
284
|
+
if share_url_ttl and share_url_ttl > 0:
|
|
285
|
+
payload["share_url_ttl"] = int(share_url_ttl)
|
|
286
|
+
share_base_url = extras.get("share_base_url")
|
|
287
|
+
if share_base_url:
|
|
288
|
+
payload["share_base_url"] = str(share_base_url)
|
|
289
|
+
if extras.get("share_preview") is not None:
|
|
290
|
+
payload["share_preview"] = bool(extras["share_preview"])
|
|
291
|
+
if extras.get("share_analytics") is not None:
|
|
292
|
+
payload["share_analytics"] = bool(extras["share_analytics"])
|
|
293
|
+
if extras.get("allow_intake") is not None:
|
|
294
|
+
payload["allow_intake"] = bool(extras["allow_intake"])
|
|
295
|
+
# Intake portal link base — same shape as share_base_url (the core drops a
|
|
296
|
+
# non-http(s) value on decode).
|
|
297
|
+
intake_base_url = extras.get("intake_base_url")
|
|
298
|
+
if intake_base_url:
|
|
299
|
+
payload["intake_base_url"] = str(intake_base_url)
|
|
300
|
+
if extras.get("allow_versioning") is not None:
|
|
301
|
+
payload["allow_versioning"] = bool(extras["allow_versioning"])
|
|
302
|
+
versioning_max = extras.get("versioning_max")
|
|
303
|
+
if versioning_max and versioning_max > 0:
|
|
304
|
+
payload["versioning_max"] = int(versioning_max)
|
|
305
|
+
versioning_max_mb = extras.get("versioning_max_mb")
|
|
306
|
+
if versioning_max_mb and versioning_max_mb > 0:
|
|
307
|
+
payload["versioning_max_mb"] = int(versioning_max_mb)
|
|
308
|
+
if extras.get("allow_webhooks") is not None:
|
|
309
|
+
payload["allow_webhooks"] = bool(extras["allow_webhooks"])
|
|
310
|
+
webhook_url = extras.get("webhook_url")
|
|
311
|
+
if webhook_url:
|
|
312
|
+
payload["webhook_url"] = str(webhook_url)
|
|
313
|
+
webhook_events = extras.get("webhook_events")
|
|
314
|
+
if webhook_events:
|
|
315
|
+
payload["webhook_events"] = list(webhook_events)
|
|
316
|
+
webhook_secret = extras.get("webhook_secret")
|
|
317
|
+
if webhook_secret:
|
|
318
|
+
payload["webhook_secret"] = str(webhook_secret)
|
|
319
|
+
if extras.get("allow_ai_vision") is not None:
|
|
320
|
+
payload["allow_ai_vision"] = bool(extras["allow_ai_vision"])
|
|
321
|
+
if extras.get("allow_ocr") is not None:
|
|
322
|
+
payload["allow_ocr"] = bool(extras["allow_ocr"])
|
|
323
|
+
if extras.get("allow_virus_scan") is not None:
|
|
324
|
+
payload["allow_virus_scan"] = bool(extras["allow_virus_scan"])
|
|
325
|
+
if extras.get("allow_backup") is not None:
|
|
326
|
+
payload["allow_backup"] = bool(extras["allow_backup"])
|
|
327
|
+
if extras.get("allow_c2pa") is not None:
|
|
328
|
+
payload["allow_c2pa"] = bool(extras["allow_c2pa"])
|
|
329
|
+
|
|
330
|
+
# Watermark overlay (preview-time; source file is never modified).
|
|
331
|
+
if extras.get("watermark_enabled"):
|
|
332
|
+
payload["watermark_enabled"] = True
|
|
333
|
+
watermark_type = extras.get("watermark_type")
|
|
334
|
+
if watermark_type:
|
|
335
|
+
payload["watermark_type"] = watermark_type
|
|
336
|
+
watermark_text = extras.get("watermark_text")
|
|
337
|
+
if watermark_text:
|
|
338
|
+
payload["watermark_text"] = str(watermark_text)
|
|
339
|
+
watermark_logo_path = extras.get("watermark_logo_path")
|
|
340
|
+
if watermark_logo_path:
|
|
341
|
+
payload["watermark_logo_path"] = str(watermark_logo_path)
|
|
342
|
+
watermark_position = extras.get("watermark_position")
|
|
343
|
+
if watermark_position:
|
|
344
|
+
payload["watermark_position"] = watermark_position
|
|
345
|
+
watermark_opacity = extras.get("watermark_opacity")
|
|
346
|
+
if watermark_opacity is not None:
|
|
347
|
+
payload["watermark_opacity"] = float(watermark_opacity)
|
|
348
|
+
watermark_font_size = extras.get("watermark_font_size")
|
|
349
|
+
if watermark_font_size and watermark_font_size > 0:
|
|
350
|
+
payload["watermark_font_size"] = int(watermark_font_size)
|
|
351
|
+
|
|
352
|
+
# Usage-dashboard claims.
|
|
353
|
+
usage_cache_ttl = extras.get("usage_cache_ttl")
|
|
354
|
+
if usage_cache_ttl and usage_cache_ttl > 0:
|
|
355
|
+
payload["usage_cache_ttl"] = int(usage_cache_ttl)
|
|
356
|
+
usage_warning_threshold = extras.get("usage_warning_threshold")
|
|
357
|
+
if usage_warning_threshold and usage_warning_threshold > 0:
|
|
358
|
+
payload["usage_warning_threshold"] = int(usage_warning_threshold)
|
|
359
|
+
usage_critical_threshold = extras.get("usage_critical_threshold")
|
|
360
|
+
if usage_critical_threshold and usage_critical_threshold > 0:
|
|
361
|
+
payload["usage_critical_threshold"] = int(usage_critical_threshold)
|
|
362
|
+
usage_top_folders_count = extras.get("usage_top_folders_count")
|
|
363
|
+
if usage_top_folders_count and usage_top_folders_count > 0:
|
|
364
|
+
payload["usage_top_folders_count"] = int(usage_top_folders_count)
|
|
365
|
+
usage_folder_depth = extras.get("usage_folder_depth")
|
|
366
|
+
if usage_folder_depth and usage_folder_depth > 0:
|
|
367
|
+
payload["usage_folder_depth"] = int(usage_folder_depth)
|
|
368
|
+
|
|
369
|
+
# Generic escape hatch: ANY claim by its raw (snake_case) name. Merged last
|
|
370
|
+
# so an explicit claim wins over a preset/group default. The server
|
|
371
|
+
# sanitizes on decode. See docs/CONFIG.md for the full claim list.
|
|
372
|
+
if claims:
|
|
373
|
+
for k, v in claims.items():
|
|
374
|
+
if v is not None:
|
|
375
|
+
payload[k] = v
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _validate_byob_disk(name: str, config: Mapping[str, Any]) -> None:
|
|
379
|
+
"""Light client-side validation. The server independently re-validates
|
|
380
|
+
(incl. SSRF checks on the endpoint), so this only catches obvious mistakes
|
|
381
|
+
early. Mirrors Node's `validateByobDisk`."""
|
|
382
|
+
driver = config.get("driver") if isinstance(config, Mapping) else None
|
|
383
|
+
if driver not in ("s3", "sftp"):
|
|
384
|
+
raise FluxFilesByobError(
|
|
385
|
+
f'FluxFiles BYOB disk "{name}": driver must be "s3" or "sftp" '
|
|
386
|
+
'(the server rejects "local").'
|
|
387
|
+
)
|
|
388
|
+
if driver == "sftp":
|
|
389
|
+
for field in ("host", "username"):
|
|
390
|
+
if not config.get(field):
|
|
391
|
+
raise FluxFilesByobError(f'FluxFiles BYOB disk "{name}": missing required "{field}".')
|
|
392
|
+
if not config.get("password") and not config.get("private_key"):
|
|
393
|
+
raise FluxFilesByobError(f'FluxFiles BYOB disk "{name}": needs a "password" or "private_key".')
|
|
394
|
+
return
|
|
395
|
+
for field in ("key", "secret", "bucket"):
|
|
396
|
+
if not config.get(field):
|
|
397
|
+
raise FluxFilesByobError(f'FluxFiles BYOB disk "{name}": missing required "{field}".')
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def create_token(
|
|
401
|
+
*,
|
|
402
|
+
# --- identity / access / quota (CreateTokenOptions base) ---
|
|
403
|
+
user_id: str,
|
|
404
|
+
secret: str | None = None,
|
|
405
|
+
perms: list[str] | None = None,
|
|
406
|
+
disks: list[str] | None = None,
|
|
407
|
+
prefix: str | None = None,
|
|
408
|
+
max_upload_mb: int | None = None,
|
|
409
|
+
allowed_ext: list[str] | None = None,
|
|
410
|
+
ttl: int | None = None,
|
|
411
|
+
owner_only: bool | None = None,
|
|
412
|
+
max_storage_mb: int | None = None,
|
|
413
|
+
max_files: int | None = None,
|
|
414
|
+
# --- presets (DX sugar, never themselves become claims) ---
|
|
415
|
+
edition: str | None = None,
|
|
416
|
+
role: str | None = None,
|
|
417
|
+
# --- per-tenant AI / rate-limit / variants ---
|
|
418
|
+
ai_auto_tag: bool | None = None,
|
|
419
|
+
rate_read: int | None = None,
|
|
420
|
+
rate_write: int | None = None,
|
|
421
|
+
variants: dict[str, int] | None = None,
|
|
422
|
+
# --- URL import ---
|
|
423
|
+
allow_url_import: bool | None = None,
|
|
424
|
+
max_import_mb: int | None = None,
|
|
425
|
+
import_url_allowlist: list[str] | None = None,
|
|
426
|
+
import_path: str | None = None,
|
|
427
|
+
import_rate_limit: int | None = None,
|
|
428
|
+
import_concurrency: int | None = None,
|
|
429
|
+
# --- media preview / streaming ---
|
|
430
|
+
media_preview: bool | None = None,
|
|
431
|
+
preview_url_ttl: int | None = None,
|
|
432
|
+
max_preview_mb: int | None = None,
|
|
433
|
+
stream_token_ttl: int | None = None,
|
|
434
|
+
# --- on-demand WebP/AVIF + responsive images ---
|
|
435
|
+
webp_enabled: bool | None = None,
|
|
436
|
+
webp_max_width: int | None = None,
|
|
437
|
+
webp_default_quality: int | None = None,
|
|
438
|
+
srcset_widths: list[int] | None = None,
|
|
439
|
+
srcset_sizes: str | None = None,
|
|
440
|
+
# --- download gate / SFTP chmod / code-edit / terminal / BYO-embed URLs ---
|
|
441
|
+
allow_download: bool | None = None,
|
|
442
|
+
allow_chmod: bool | None = None,
|
|
443
|
+
allow_code_edit: bool | None = None,
|
|
444
|
+
allow_terminal: bool | None = None,
|
|
445
|
+
terminal_pty_url: str | None = None,
|
|
446
|
+
pdf_tools_url: str | None = None,
|
|
447
|
+
office_url: str | None = None,
|
|
448
|
+
esign_url: str | None = None,
|
|
449
|
+
# --- optimization (free/core) ---
|
|
450
|
+
allow_optimize: bool | None = None,
|
|
451
|
+
auto_optimize: bool | None = None,
|
|
452
|
+
optimize_quality: int | None = None,
|
|
453
|
+
optimize_keep_original: bool | None = None,
|
|
454
|
+
optimize_max_mb: int | None = None,
|
|
455
|
+
pdf_level: str | None = None, # "screen"|"ebook"|"printer"|"prepress"|"default"
|
|
456
|
+
# --- upload behavior / listing ---
|
|
457
|
+
upload_collision: str | None = None, # "rename"|"overwrite"|"reject"
|
|
458
|
+
show_hidden: bool | None = None,
|
|
459
|
+
dedupe_uploads: bool | None = None,
|
|
460
|
+
# --- zip/extract ---
|
|
461
|
+
allow_zip: bool | None = None,
|
|
462
|
+
allow_extract: bool | None = None,
|
|
463
|
+
zip_max_mb: int | None = None,
|
|
464
|
+
zip_max_files: int | None = None,
|
|
465
|
+
# --- paid modules (all default off; inert unless installed + licensed) ---
|
|
466
|
+
allow_share: bool | None = None,
|
|
467
|
+
share_url_ttl: int | None = None,
|
|
468
|
+
share_base_url: str | None = None,
|
|
469
|
+
share_preview: bool | None = None,
|
|
470
|
+
share_analytics: bool | None = None,
|
|
471
|
+
allow_intake: bool | None = None,
|
|
472
|
+
intake_base_url: str | None = None,
|
|
473
|
+
allow_versioning: bool | None = None,
|
|
474
|
+
versioning_max: int | None = None,
|
|
475
|
+
versioning_max_mb: int | None = None,
|
|
476
|
+
allow_webhooks: bool | None = None,
|
|
477
|
+
webhook_url: str | None = None,
|
|
478
|
+
webhook_events: list[str] | None = None,
|
|
479
|
+
webhook_secret: str | None = None,
|
|
480
|
+
allow_ai_vision: bool | None = None,
|
|
481
|
+
allow_ocr: bool | None = None,
|
|
482
|
+
allow_virus_scan: bool | None = None,
|
|
483
|
+
allow_backup: bool | None = None,
|
|
484
|
+
allow_c2pa: bool | None = None,
|
|
485
|
+
# --- watermark overlay ---
|
|
486
|
+
watermark_enabled: bool | None = None,
|
|
487
|
+
watermark_type: str | None = None, # "text" | "logo"
|
|
488
|
+
watermark_text: str | None = None,
|
|
489
|
+
watermark_logo_path: str | None = None,
|
|
490
|
+
watermark_position: str | None = None,
|
|
491
|
+
watermark_opacity: float | None = None,
|
|
492
|
+
watermark_font_size: int | None = None,
|
|
493
|
+
# --- usage dashboard ---
|
|
494
|
+
usage_cache_ttl: int | None = None,
|
|
495
|
+
usage_warning_threshold: int | None = None,
|
|
496
|
+
usage_critical_threshold: int | None = None,
|
|
497
|
+
usage_top_folders_count: int | None = None,
|
|
498
|
+
usage_folder_depth: int | None = None,
|
|
499
|
+
# --- escape hatch: anything not yet a typed kwarg above ---
|
|
500
|
+
claims: dict[str, Any] | None = None,
|
|
501
|
+
) -> str:
|
|
502
|
+
"""Mint a standard FluxFiles JWT. The payload mirrors the PHP
|
|
503
|
+
`fluxfiles_token()` helper exactly, so the FluxFiles core decodes it
|
|
504
|
+
natively."""
|
|
505
|
+
resolved_secret = _resolve_secret(secret)
|
|
506
|
+
now = int(time.time())
|
|
507
|
+
|
|
508
|
+
# Role preset (DX sugar, docs/ACL-ROLE-PRESETS-DESIGN.md): resolved BEFORE
|
|
509
|
+
# the base payload dict, because `perms`/`owner_only` already have an
|
|
510
|
+
# unconditional default baked into that dict below — a plain "set if
|
|
511
|
+
# absent" guard would never fire for them.
|
|
512
|
+
role_preset = ROLE_PRESETS.get(role.lower()) if role else None
|
|
513
|
+
resolved_perms = (
|
|
514
|
+
perms
|
|
515
|
+
if perms is not None
|
|
516
|
+
else role_preset.get("perms") if role_preset is not None else ["read"]
|
|
517
|
+
)
|
|
518
|
+
resolved_owner_only = (
|
|
519
|
+
owner_only
|
|
520
|
+
if owner_only is not None
|
|
521
|
+
else role_preset.get("owner_only") if role_preset is not None else False
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
payload: dict[str, Any] = {
|
|
525
|
+
"sub": str(user_id),
|
|
526
|
+
"iat": now,
|
|
527
|
+
"exp": now + (ttl if ttl is not None else 3600),
|
|
528
|
+
"jti": _new_jti(),
|
|
529
|
+
"perms": resolved_perms,
|
|
530
|
+
"disks": disks if disks is not None else ["local"],
|
|
531
|
+
"prefix": prefix if prefix is not None else "",
|
|
532
|
+
"max_upload": max_upload_mb if max_upload_mb is not None else 10,
|
|
533
|
+
"allowed_ext": allowed_ext,
|
|
534
|
+
"max_storage": max_storage_mb if max_storage_mb is not None else 0,
|
|
535
|
+
"max_files": max_files if max_files is not None else 0,
|
|
536
|
+
}
|
|
537
|
+
if resolved_owner_only:
|
|
538
|
+
payload["owner_only"] = True
|
|
539
|
+
|
|
540
|
+
extras: dict[str, Any] = dict(
|
|
541
|
+
ai_auto_tag=ai_auto_tag,
|
|
542
|
+
rate_read=rate_read,
|
|
543
|
+
rate_write=rate_write,
|
|
544
|
+
variants=variants,
|
|
545
|
+
allow_url_import=allow_url_import,
|
|
546
|
+
max_import_mb=max_import_mb,
|
|
547
|
+
import_url_allowlist=import_url_allowlist,
|
|
548
|
+
import_path=import_path,
|
|
549
|
+
import_rate_limit=import_rate_limit,
|
|
550
|
+
import_concurrency=import_concurrency,
|
|
551
|
+
media_preview=media_preview,
|
|
552
|
+
preview_url_ttl=preview_url_ttl,
|
|
553
|
+
max_preview_mb=max_preview_mb,
|
|
554
|
+
stream_token_ttl=stream_token_ttl,
|
|
555
|
+
webp_enabled=webp_enabled,
|
|
556
|
+
webp_max_width=webp_max_width,
|
|
557
|
+
webp_default_quality=webp_default_quality,
|
|
558
|
+
srcset_widths=srcset_widths,
|
|
559
|
+
srcset_sizes=srcset_sizes,
|
|
560
|
+
allow_download=allow_download,
|
|
561
|
+
allow_chmod=allow_chmod,
|
|
562
|
+
allow_code_edit=allow_code_edit,
|
|
563
|
+
allow_terminal=allow_terminal,
|
|
564
|
+
terminal_pty_url=terminal_pty_url,
|
|
565
|
+
pdf_tools_url=pdf_tools_url,
|
|
566
|
+
office_url=office_url,
|
|
567
|
+
esign_url=esign_url,
|
|
568
|
+
allow_optimize=allow_optimize,
|
|
569
|
+
auto_optimize=auto_optimize,
|
|
570
|
+
optimize_quality=optimize_quality,
|
|
571
|
+
optimize_keep_original=optimize_keep_original,
|
|
572
|
+
optimize_max_mb=optimize_max_mb,
|
|
573
|
+
pdf_level=pdf_level,
|
|
574
|
+
upload_collision=upload_collision,
|
|
575
|
+
show_hidden=show_hidden,
|
|
576
|
+
dedupe_uploads=dedupe_uploads,
|
|
577
|
+
allow_zip=allow_zip,
|
|
578
|
+
allow_extract=allow_extract,
|
|
579
|
+
zip_max_mb=zip_max_mb,
|
|
580
|
+
zip_max_files=zip_max_files,
|
|
581
|
+
allow_share=allow_share,
|
|
582
|
+
share_url_ttl=share_url_ttl,
|
|
583
|
+
share_base_url=share_base_url,
|
|
584
|
+
share_preview=share_preview,
|
|
585
|
+
share_analytics=share_analytics,
|
|
586
|
+
allow_intake=allow_intake,
|
|
587
|
+
intake_base_url=intake_base_url,
|
|
588
|
+
allow_versioning=allow_versioning,
|
|
589
|
+
versioning_max=versioning_max,
|
|
590
|
+
versioning_max_mb=versioning_max_mb,
|
|
591
|
+
allow_webhooks=allow_webhooks,
|
|
592
|
+
webhook_url=webhook_url,
|
|
593
|
+
webhook_events=webhook_events,
|
|
594
|
+
webhook_secret=webhook_secret,
|
|
595
|
+
allow_ai_vision=allow_ai_vision,
|
|
596
|
+
allow_ocr=allow_ocr,
|
|
597
|
+
allow_virus_scan=allow_virus_scan,
|
|
598
|
+
allow_backup=allow_backup,
|
|
599
|
+
allow_c2pa=allow_c2pa,
|
|
600
|
+
watermark_enabled=watermark_enabled,
|
|
601
|
+
watermark_type=watermark_type,
|
|
602
|
+
watermark_text=watermark_text,
|
|
603
|
+
watermark_logo_path=watermark_logo_path,
|
|
604
|
+
watermark_position=watermark_position,
|
|
605
|
+
watermark_opacity=watermark_opacity,
|
|
606
|
+
watermark_font_size=watermark_font_size,
|
|
607
|
+
usage_cache_ttl=usage_cache_ttl,
|
|
608
|
+
usage_warning_threshold=usage_warning_threshold,
|
|
609
|
+
usage_critical_threshold=usage_critical_threshold,
|
|
610
|
+
usage_top_folders_count=usage_top_folders_count,
|
|
611
|
+
usage_folder_depth=usage_folder_depth,
|
|
612
|
+
)
|
|
613
|
+
_apply_tenant_overrides(payload, extras, edition, role_preset, claims)
|
|
614
|
+
return _sign(payload, resolved_secret)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def create_byob_token(
|
|
618
|
+
*,
|
|
619
|
+
user_id: str,
|
|
620
|
+
byob_disks: dict[str, ByobDiskConfig],
|
|
621
|
+
secret: str | None = None,
|
|
622
|
+
perms: list[str] | None = None,
|
|
623
|
+
prefix: str | None = None,
|
|
624
|
+
ttl: int | None = None,
|
|
625
|
+
max_upload_mb: int | None = None,
|
|
626
|
+
allowed_ext: list[str] | None = None,
|
|
627
|
+
owner_only: bool | None = None,
|
|
628
|
+
# --- presets: INCLUDED on BYOB tokens (decided — see design doc §5.1) ---
|
|
629
|
+
edition: str | None = None,
|
|
630
|
+
role: str | None = None,
|
|
631
|
+
# --- identical typed kwargs as create_token(), present 1:1 ---
|
|
632
|
+
ai_auto_tag: bool | None = None,
|
|
633
|
+
rate_read: int | None = None,
|
|
634
|
+
rate_write: int | None = None,
|
|
635
|
+
variants: dict[str, int] | None = None,
|
|
636
|
+
allow_url_import: bool | None = None,
|
|
637
|
+
max_import_mb: int | None = None,
|
|
638
|
+
import_url_allowlist: list[str] | None = None,
|
|
639
|
+
import_path: str | None = None,
|
|
640
|
+
import_rate_limit: int | None = None,
|
|
641
|
+
import_concurrency: int | None = None,
|
|
642
|
+
media_preview: bool | None = None,
|
|
643
|
+
preview_url_ttl: int | None = None,
|
|
644
|
+
max_preview_mb: int | None = None,
|
|
645
|
+
stream_token_ttl: int | None = None,
|
|
646
|
+
webp_enabled: bool | None = None,
|
|
647
|
+
webp_max_width: int | None = None,
|
|
648
|
+
webp_default_quality: int | None = None,
|
|
649
|
+
srcset_widths: list[int] | None = None,
|
|
650
|
+
srcset_sizes: str | None = None,
|
|
651
|
+
allow_download: bool | None = None,
|
|
652
|
+
allow_chmod: bool | None = None,
|
|
653
|
+
allow_code_edit: bool | None = None,
|
|
654
|
+
allow_terminal: bool | None = None,
|
|
655
|
+
terminal_pty_url: str | None = None,
|
|
656
|
+
pdf_tools_url: str | None = None,
|
|
657
|
+
office_url: str | None = None,
|
|
658
|
+
esign_url: str | None = None,
|
|
659
|
+
allow_optimize: bool | None = None,
|
|
660
|
+
auto_optimize: bool | None = None,
|
|
661
|
+
optimize_quality: int | None = None,
|
|
662
|
+
optimize_keep_original: bool | None = None,
|
|
663
|
+
optimize_max_mb: int | None = None,
|
|
664
|
+
pdf_level: str | None = None,
|
|
665
|
+
upload_collision: str | None = None,
|
|
666
|
+
show_hidden: bool | None = None,
|
|
667
|
+
dedupe_uploads: bool | None = None,
|
|
668
|
+
allow_zip: bool | None = None,
|
|
669
|
+
allow_extract: bool | None = None,
|
|
670
|
+
zip_max_mb: int | None = None,
|
|
671
|
+
zip_max_files: int | None = None,
|
|
672
|
+
allow_share: bool | None = None,
|
|
673
|
+
share_url_ttl: int | None = None,
|
|
674
|
+
share_base_url: str | None = None,
|
|
675
|
+
share_preview: bool | None = None,
|
|
676
|
+
share_analytics: bool | None = None,
|
|
677
|
+
allow_intake: bool | None = None,
|
|
678
|
+
intake_base_url: str | None = None,
|
|
679
|
+
allow_versioning: bool | None = None,
|
|
680
|
+
versioning_max: int | None = None,
|
|
681
|
+
versioning_max_mb: int | None = None,
|
|
682
|
+
allow_webhooks: bool | None = None,
|
|
683
|
+
webhook_url: str | None = None,
|
|
684
|
+
webhook_events: list[str] | None = None,
|
|
685
|
+
webhook_secret: str | None = None,
|
|
686
|
+
allow_ai_vision: bool | None = None,
|
|
687
|
+
allow_ocr: bool | None = None,
|
|
688
|
+
allow_virus_scan: bool | None = None,
|
|
689
|
+
allow_backup: bool | None = None,
|
|
690
|
+
allow_c2pa: bool | None = None,
|
|
691
|
+
watermark_enabled: bool | None = None,
|
|
692
|
+
watermark_type: str | None = None,
|
|
693
|
+
watermark_text: str | None = None,
|
|
694
|
+
watermark_logo_path: str | None = None,
|
|
695
|
+
watermark_position: str | None = None,
|
|
696
|
+
watermark_opacity: float | None = None,
|
|
697
|
+
watermark_font_size: int | None = None,
|
|
698
|
+
usage_cache_ttl: int | None = None,
|
|
699
|
+
usage_warning_threshold: int | None = None,
|
|
700
|
+
usage_critical_threshold: int | None = None,
|
|
701
|
+
usage_top_folders_count: int | None = None,
|
|
702
|
+
usage_folder_depth: int | None = None,
|
|
703
|
+
claims: dict[str, Any] | None = None,
|
|
704
|
+
) -> str:
|
|
705
|
+
"""Mint a BYOB token. Each disk's S3-compatible/SFTP credentials are
|
|
706
|
+
AES-256-GCM encrypted into the token (decrypted only at runtime by the
|
|
707
|
+
server). Mirrors the PHP `fluxfiles_byob_token()` helper, but — unlike
|
|
708
|
+
core/Node's original `createByobToken()` — also supports `role`/`edition`
|
|
709
|
+
presets, following the Laravel/WordPress precedent (design doc §5.1):
|
|
710
|
+
|
|
711
|
+
1. Encrypt each `byob_disks` entry (independent of any preset).
|
|
712
|
+
2. `disks` = the BYOB disk names (this SDK has no server-config store to
|
|
713
|
+
merge against, unlike Laravel/WordPress).
|
|
714
|
+
3. Resolve `perms`/`owner_only` early (BYOB's own base default is
|
|
715
|
+
`["read", "write"]`, unlike plain tokens' `["read"]`).
|
|
716
|
+
4. Build the base payload (encrypted `byob_disks`, `disks`, `perms`,
|
|
717
|
+
`owner_only`).
|
|
718
|
+
5-6. Apply the edition preset, then the role preset (excluding
|
|
719
|
+
`perms`/`owner_only`, already resolved in step 3) — fills absent keys.
|
|
720
|
+
7. Apply every other typed kwarg — explicit values always win.
|
|
721
|
+
8. Apply the `claims` escape hatch last — always wins.
|
|
722
|
+
"""
|
|
723
|
+
resolved_secret = _resolve_secret(secret)
|
|
724
|
+
now = int(time.time())
|
|
725
|
+
|
|
726
|
+
# Step 1: encrypt each BYOB disk entry.
|
|
727
|
+
encrypted: dict[str, str] = {}
|
|
728
|
+
names: list[str] = []
|
|
729
|
+
for name, config in byob_disks.items():
|
|
730
|
+
_validate_byob_disk(name, config)
|
|
731
|
+
encrypted[name] = encrypt_byob(dict(config), resolved_secret)
|
|
732
|
+
names.append(name)
|
|
733
|
+
|
|
734
|
+
# Step 2: disks = BYOB disk names only (no server-config merge).
|
|
735
|
+
resolved_disks = names
|
|
736
|
+
|
|
737
|
+
# Step 3: resolve perms/owner_only early.
|
|
738
|
+
role_preset = ROLE_PRESETS.get(role.lower()) if role else None
|
|
739
|
+
resolved_perms = (
|
|
740
|
+
perms
|
|
741
|
+
if perms is not None
|
|
742
|
+
else role_preset.get("perms") if role_preset is not None else ["read", "write"]
|
|
743
|
+
)
|
|
744
|
+
resolved_owner_only = (
|
|
745
|
+
owner_only
|
|
746
|
+
if owner_only is not None
|
|
747
|
+
else role_preset.get("owner_only") if role_preset is not None else False
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
# Step 4: base payload.
|
|
751
|
+
payload: dict[str, Any] = {
|
|
752
|
+
"sub": str(user_id),
|
|
753
|
+
"iat": now,
|
|
754
|
+
"exp": now + (ttl if ttl is not None else 1800),
|
|
755
|
+
"jti": _new_jti(),
|
|
756
|
+
"perms": resolved_perms,
|
|
757
|
+
"disks": resolved_disks,
|
|
758
|
+
"prefix": prefix if prefix is not None else "",
|
|
759
|
+
"max_upload": max_upload_mb if max_upload_mb is not None else 10,
|
|
760
|
+
"allowed_ext": allowed_ext,
|
|
761
|
+
"byob_disks": encrypted,
|
|
762
|
+
}
|
|
763
|
+
if resolved_owner_only:
|
|
764
|
+
payload["owner_only"] = True
|
|
765
|
+
|
|
766
|
+
# Steps 5-8: edition preset -> role preset -> typed kwargs -> claims.
|
|
767
|
+
extras: dict[str, Any] = dict(
|
|
768
|
+
ai_auto_tag=ai_auto_tag,
|
|
769
|
+
rate_read=rate_read,
|
|
770
|
+
rate_write=rate_write,
|
|
771
|
+
variants=variants,
|
|
772
|
+
allow_url_import=allow_url_import,
|
|
773
|
+
max_import_mb=max_import_mb,
|
|
774
|
+
import_url_allowlist=import_url_allowlist,
|
|
775
|
+
import_path=import_path,
|
|
776
|
+
import_rate_limit=import_rate_limit,
|
|
777
|
+
import_concurrency=import_concurrency,
|
|
778
|
+
media_preview=media_preview,
|
|
779
|
+
preview_url_ttl=preview_url_ttl,
|
|
780
|
+
max_preview_mb=max_preview_mb,
|
|
781
|
+
stream_token_ttl=stream_token_ttl,
|
|
782
|
+
webp_enabled=webp_enabled,
|
|
783
|
+
webp_max_width=webp_max_width,
|
|
784
|
+
webp_default_quality=webp_default_quality,
|
|
785
|
+
srcset_widths=srcset_widths,
|
|
786
|
+
srcset_sizes=srcset_sizes,
|
|
787
|
+
allow_download=allow_download,
|
|
788
|
+
allow_chmod=allow_chmod,
|
|
789
|
+
allow_code_edit=allow_code_edit,
|
|
790
|
+
allow_terminal=allow_terminal,
|
|
791
|
+
terminal_pty_url=terminal_pty_url,
|
|
792
|
+
pdf_tools_url=pdf_tools_url,
|
|
793
|
+
office_url=office_url,
|
|
794
|
+
esign_url=esign_url,
|
|
795
|
+
allow_optimize=allow_optimize,
|
|
796
|
+
auto_optimize=auto_optimize,
|
|
797
|
+
optimize_quality=optimize_quality,
|
|
798
|
+
optimize_keep_original=optimize_keep_original,
|
|
799
|
+
optimize_max_mb=optimize_max_mb,
|
|
800
|
+
pdf_level=pdf_level,
|
|
801
|
+
upload_collision=upload_collision,
|
|
802
|
+
show_hidden=show_hidden,
|
|
803
|
+
dedupe_uploads=dedupe_uploads,
|
|
804
|
+
allow_zip=allow_zip,
|
|
805
|
+
allow_extract=allow_extract,
|
|
806
|
+
zip_max_mb=zip_max_mb,
|
|
807
|
+
zip_max_files=zip_max_files,
|
|
808
|
+
allow_share=allow_share,
|
|
809
|
+
share_url_ttl=share_url_ttl,
|
|
810
|
+
share_base_url=share_base_url,
|
|
811
|
+
share_preview=share_preview,
|
|
812
|
+
share_analytics=share_analytics,
|
|
813
|
+
allow_intake=allow_intake,
|
|
814
|
+
intake_base_url=intake_base_url,
|
|
815
|
+
allow_versioning=allow_versioning,
|
|
816
|
+
versioning_max=versioning_max,
|
|
817
|
+
versioning_max_mb=versioning_max_mb,
|
|
818
|
+
allow_webhooks=allow_webhooks,
|
|
819
|
+
webhook_url=webhook_url,
|
|
820
|
+
webhook_events=webhook_events,
|
|
821
|
+
webhook_secret=webhook_secret,
|
|
822
|
+
allow_ai_vision=allow_ai_vision,
|
|
823
|
+
allow_ocr=allow_ocr,
|
|
824
|
+
allow_virus_scan=allow_virus_scan,
|
|
825
|
+
allow_backup=allow_backup,
|
|
826
|
+
allow_c2pa=allow_c2pa,
|
|
827
|
+
watermark_enabled=watermark_enabled,
|
|
828
|
+
watermark_type=watermark_type,
|
|
829
|
+
watermark_text=watermark_text,
|
|
830
|
+
watermark_logo_path=watermark_logo_path,
|
|
831
|
+
watermark_position=watermark_position,
|
|
832
|
+
watermark_opacity=watermark_opacity,
|
|
833
|
+
watermark_font_size=watermark_font_size,
|
|
834
|
+
usage_cache_ttl=usage_cache_ttl,
|
|
835
|
+
usage_warning_threshold=usage_warning_threshold,
|
|
836
|
+
usage_critical_threshold=usage_critical_threshold,
|
|
837
|
+
usage_top_folders_count=usage_top_folders_count,
|
|
838
|
+
usage_folder_depth=usage_folder_depth,
|
|
839
|
+
)
|
|
840
|
+
_apply_tenant_overrides(payload, extras, edition, role_preset, claims)
|
|
841
|
+
return _sign(payload, resolved_secret)
|
fluxfiles_token/types.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Typed shapes for FluxFiles tokens: BYOB disk configs and the decoded claims
|
|
2
|
+
payload. Mirrors packages/node/src/types.ts 1:1 (camelCase -> snake_case)."""
|
|
3
|
+
|
|
4
|
+
from typing import Literal, TypedDict
|
|
5
|
+
|
|
6
|
+
# A FluxFiles permission. `audit` gates reading the activity log.
|
|
7
|
+
FluxPermission = Literal["read", "write", "delete", "audit"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _ByobS3DiskConfigRequired(TypedDict):
|
|
11
|
+
driver: Literal["s3"]
|
|
12
|
+
key: str
|
|
13
|
+
secret: str
|
|
14
|
+
bucket: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ByobS3DiskConfig(_ByobS3DiskConfigRequired, total=False):
|
|
18
|
+
"""A BYOB (Bring Your Own Bucket) S3-compatible disk config. Encrypted into
|
|
19
|
+
the JWT and decrypted only at runtime by the FluxFiles server. Only
|
|
20
|
+
S3-compatible storage is allowed — the server rejects the `local` driver.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
region: str
|
|
24
|
+
# Custom S3 endpoint (R2, MinIO, Spaces, …). Omit for native AWS S3.
|
|
25
|
+
endpoint: str
|
|
26
|
+
visibility: Literal["private", "public"]
|
|
27
|
+
# Public base URL for direct (unsigned) object links on a public disk.
|
|
28
|
+
public_url: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _ByobSftpDiskConfigRequired(TypedDict):
|
|
32
|
+
driver: Literal["sftp"]
|
|
33
|
+
host: str
|
|
34
|
+
username: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ByobSftpDiskConfig(_ByobSftpDiskConfigRequired, total=False):
|
|
38
|
+
"""A BYOB SFTP disk — a user's own SFTP server (e.g. a VPS). Auth is a
|
|
39
|
+
password OR a private key. The server SSRF-checks the host (no
|
|
40
|
+
loopback/private/metadata targets). SFTP files are streamed through the
|
|
41
|
+
app (no static/presigned URL).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
password: str
|
|
45
|
+
private_key: str
|
|
46
|
+
private_key_passphrase: str
|
|
47
|
+
port: int
|
|
48
|
+
root: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
ByobDiskConfig = ByobS3DiskConfig | ByobSftpDiskConfig
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FluxClaims(TypedDict, total=False):
|
|
55
|
+
"""Decoded JWT payload (snake_case, as emitted by the PHP core). `total=False`
|
|
56
|
+
since every claim beyond `sub`/`iat`/`exp`/`jti` is conditionally present —
|
|
57
|
+
and a newer core may emit claims this SDK predates."""
|
|
58
|
+
|
|
59
|
+
sub: str
|
|
60
|
+
iat: int
|
|
61
|
+
exp: int
|
|
62
|
+
jti: str
|
|
63
|
+
perms: list[str]
|
|
64
|
+
disks: list[str]
|
|
65
|
+
prefix: str
|
|
66
|
+
max_upload: int
|
|
67
|
+
allowed_ext: list[str] | None
|
|
68
|
+
max_storage: int
|
|
69
|
+
max_files: int
|
|
70
|
+
owner_only: bool
|
|
71
|
+
byob_disks: dict[str, str]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Thin PyJWT wrappers: verify (signature + expiry) and decode (inspection only)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
|
|
8
|
+
from .exceptions import FluxFilesTokenError
|
|
9
|
+
from .types import FluxClaims
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def verify_token(token: str, secret: str | None = None) -> FluxClaims:
|
|
13
|
+
"""Verify an HS256 FluxFiles token: signature + expiry. Returns the decoded
|
|
14
|
+
claims, or raises on a bad signature, wrong algorithm, or an expired token.
|
|
15
|
+
|
|
16
|
+
PyJWT's own exceptions propagate unwrapped (`jwt.ExpiredSignatureError`,
|
|
17
|
+
`jwt.InvalidSignatureError`, …) — a Python user of a JWT-adjacent library
|
|
18
|
+
expects PyJWT's exception hierarchy, not a re-wrapped one.
|
|
19
|
+
"""
|
|
20
|
+
key = secret if secret is not None else os.environ.get("FLUXFILES_SECRET", "")
|
|
21
|
+
if not key:
|
|
22
|
+
raise FluxFilesTokenError("FluxFiles: no secret provided to verify_token")
|
|
23
|
+
claims = jwt.decode(token, key, algorithms=["HS256"])
|
|
24
|
+
return cast(FluxClaims, claims)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def decode_token(token: str) -> FluxClaims:
|
|
28
|
+
"""Decode a token WITHOUT verifying the signature. Useful for
|
|
29
|
+
inspection/logging; never trust the result for authorization — use
|
|
30
|
+
verify_token() instead."""
|
|
31
|
+
claims = jwt.decode(token, options={"verify_signature": False})
|
|
32
|
+
return cast(FluxClaims, claims)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fluxfiles-token
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-side Python SDK for minting FluxFiles JWTs (plain + BYOB), byte-compatible with the PHP core and @fluxfiles/node
|
|
5
|
+
Project-URL: Homepage, https://github.com/thai-pc/fluxfiles#python-server-side-token-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/thai-pc/fluxfiles
|
|
7
|
+
Project-URL: Issues, https://github.com/thai-pc/fluxfiles/issues
|
|
8
|
+
Author: thai-pc
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: byob,file-manager,fluxfiles,jwt,r2,s3,server,token
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: cryptography>=42
|
|
14
|
+
Requires-Dist: pyjwt<3,>=2.8
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# FluxFiles for Python (server-side token SDK)
|
|
22
|
+
|
|
23
|
+
Mint [FluxFiles](https://github.com/thai-pc/fluxfiles) JWTs from any Python
|
|
24
|
+
backend (Django, FastAPI, Flask, …). Tokens are **byte-compatible with the PHP
|
|
25
|
+
core**, so non-PHP apps can issue access tokens — including encrypted BYOB
|
|
26
|
+
(Bring Your Own Bucket) credentials — without running PHP.
|
|
27
|
+
|
|
28
|
+
> **This package only issues tokens — it is not a backend.** You still run a
|
|
29
|
+
> FluxFiles **core service** (the file-manager backend that talks to storage;
|
|
30
|
+
> a PHP app, e.g. the Docker image) for the token to authenticate against.
|
|
31
|
+
> `fluxfiles-token` simply removes the need for *your app* to be PHP in order
|
|
32
|
+
> to mint those tokens.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- Python 3.10+
|
|
37
|
+
- A running FluxFiles **core service** the issued tokens authenticate against
|
|
38
|
+
(the SDK/iframe `endpoint` points at it).
|
|
39
|
+
- The same **`FLUXFILES_SECRET`** your FluxFiles core server uses to verify
|
|
40
|
+
tokens (HS256, **must be ≥ 32 bytes**). Keep it server-side only.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install fluxfiles-token
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Mint a token
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from fluxfiles_token import create_token
|
|
54
|
+
|
|
55
|
+
token = create_token(
|
|
56
|
+
user_id="user-42",
|
|
57
|
+
perms=["read", "write"],
|
|
58
|
+
disks=["local", "s3"],
|
|
59
|
+
prefix="users/42", # scope the user to their own directory
|
|
60
|
+
max_upload_mb=25,
|
|
61
|
+
allowed_ext=["png", "jpg", "pdf"],
|
|
62
|
+
ttl=3600, # seconds
|
|
63
|
+
# secret=..., # or omit to read FLUXFILES_SECRET from the environment
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Role & edition presets
|
|
68
|
+
|
|
69
|
+
Mint-time-only DX sugar — they expand into ordinary claims and are never
|
|
70
|
+
themselves a JWT claim. Explicit kwargs always win over a preset default.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from fluxfiles_token import create_token
|
|
74
|
+
|
|
75
|
+
# perms=["read", "write"], owner_only=True, allow_extract=True,
|
|
76
|
+
# allow_chmod=False (the "editor" bundle).
|
|
77
|
+
token = create_token(user_id="user-42", role="editor")
|
|
78
|
+
|
|
79
|
+
# Every module claim the "enterprise" edition grants — license/module
|
|
80
|
+
# install are still the real gate; a preset just defaults the per-tenant claim.
|
|
81
|
+
token = create_token(user_id="tenant-9", edition="enterprise")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Enable Import from URL
|
|
85
|
+
|
|
86
|
+
Import-from-URL is **off by default**. Turn it on for a token by setting the
|
|
87
|
+
import options — no server-side per-tenant config is needed:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
token = create_token(
|
|
91
|
+
user_id="user-42",
|
|
92
|
+
perms=["read", "write"],
|
|
93
|
+
allow_url_import=True, # required — enables the feature
|
|
94
|
+
max_import_mb=20, # optional — cap per import (MB)
|
|
95
|
+
import_url_allowlist=["*.unsplash.com"], # optional — restrict source hosts
|
|
96
|
+
# import_path, import_rate_limit, import_concurrency also supported
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The core then accepts `POST /api/fm/import-url` for that token (SSRF-guarded,
|
|
101
|
+
sharing the quota/dedup/variants pipeline). Server-wide defaults come from
|
|
102
|
+
`FLUXFILES_IMPORT_*` env vars on the core service.
|
|
103
|
+
|
|
104
|
+
### SFTP disk: chmod & SSH terminal
|
|
105
|
+
|
|
106
|
+
When the token targets a FluxFiles server that has an **SFTP disk** configured
|
|
107
|
+
(`SFTP_*` env on that server — see the
|
|
108
|
+
[core README](https://github.com/thai-pc/fluxfiles#sftp-disk-vps--shared-hosting)),
|
|
109
|
+
you can hand a token the SFTP file-manager tools. `chmod` is on by default for
|
|
110
|
+
SFTP; the **SSH terminal** is opt-in (it grants shell access as the SSH user):
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
token = create_token(
|
|
114
|
+
user_id="admin-7",
|
|
115
|
+
perms=["read", "write"],
|
|
116
|
+
disks=["sftp"], # an SFTP disk configured on the FluxFiles server
|
|
117
|
+
allow_chmod=True, # cPanel-style permissions (default on for SFTP)
|
|
118
|
+
allow_terminal=True, # SSH terminal — opt-in, off by default
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
These are **standalone-core** features: `fluxfiles-token` mints for a real
|
|
123
|
+
FluxFiles server (Docker / standalone), which serves them — they aren't
|
|
124
|
+
available behind the WordPress / Laravel-proxy adapters.
|
|
125
|
+
|
|
126
|
+
### BYOB — encrypt a user's own bucket credentials
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import os
|
|
130
|
+
from fluxfiles_token import create_byob_token
|
|
131
|
+
|
|
132
|
+
token = create_byob_token(
|
|
133
|
+
user_id="user-42",
|
|
134
|
+
byob_disks={
|
|
135
|
+
"my-s3": {
|
|
136
|
+
"driver": "s3",
|
|
137
|
+
"key": os.environ["USER_AWS_KEY"],
|
|
138
|
+
"secret": os.environ["USER_AWS_SECRET"],
|
|
139
|
+
"bucket": "user-personal-bucket",
|
|
140
|
+
"region": "us-east-1",
|
|
141
|
+
# "endpoint": "https://<acct>.r2.cloudflarestorage.com", # R2/MinIO/Spaces
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Credentials are AES-256-GCM encrypted into the token and decrypted only at
|
|
148
|
+
runtime by the FluxFiles server (which also re-validates the endpoint for
|
|
149
|
+
SSRF). `role`/`edition` presets are supported on BYOB tokens too.
|
|
150
|
+
|
|
151
|
+
### Verify / decode (optional)
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from fluxfiles_token import verify_token, decode_token
|
|
155
|
+
|
|
156
|
+
claims = verify_token(token) # checks HS256 signature + expiry, raises on failure
|
|
157
|
+
peek = decode_token(token) # decode only, NO verification — never trust for auth
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Django view
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from django.http import JsonResponse
|
|
164
|
+
from fluxfiles_token import create_token
|
|
165
|
+
|
|
166
|
+
def fluxfiles_token_view(request):
|
|
167
|
+
token = create_token(
|
|
168
|
+
user_id=str(request.user.id),
|
|
169
|
+
perms=["read", "write"],
|
|
170
|
+
prefix=f"users/{request.user.id}",
|
|
171
|
+
)
|
|
172
|
+
return JsonResponse({"token": token})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### FastAPI route
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from fastapi import APIRouter, Depends
|
|
179
|
+
from fluxfiles_token import create_token
|
|
180
|
+
|
|
181
|
+
router = APIRouter()
|
|
182
|
+
|
|
183
|
+
@router.get("/fluxfiles/token")
|
|
184
|
+
def fluxfiles_token(user=Depends(get_current_user)):
|
|
185
|
+
return {"token": create_token(user_id=user.id, perms=["read", "write"])}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## API
|
|
189
|
+
|
|
190
|
+
| Function | Description |
|
|
191
|
+
|----------|-------------|
|
|
192
|
+
| `create_token(**opts)` | Standard token. Mirrors PHP `fluxfiles_token()`. |
|
|
193
|
+
| `create_byob_token(**opts)` | Token with encrypted BYOB disk credentials. Mirrors `fluxfiles_byob_token()`. |
|
|
194
|
+
| `verify_token(token, secret=None)` | Verify HS256 signature + expiry; returns decoded claims or raises. |
|
|
195
|
+
| `decode_token(token)` | Decode without verifying (inspection/logging only). |
|
|
196
|
+
|
|
197
|
+
`create_token` keyword arguments: `secret=None`, `user_id`, `perms=None`,
|
|
198
|
+
`disks=None`, `prefix=None`, `max_upload_mb=None`, `allowed_ext=None`,
|
|
199
|
+
`ttl=None`, `owner_only=None`, `max_storage_mb=None`, `max_files=None`,
|
|
200
|
+
`edition=None`, `role=None`. Per-tenant overrides (omit to inherit the server
|
|
201
|
+
default): `ai_auto_tag=None` (bool), `rate_read=None` / `rate_write=None`
|
|
202
|
+
(req/min), `variants=None` (`{"thumb": .., "medium": .., "large": ..}` px),
|
|
203
|
+
and every other `allow_*`/module claim documented in
|
|
204
|
+
[`docs/CONFIG.md`](https://github.com/thai-pc/fluxfiles/blob/master/docs/CONFIG.md) —
|
|
205
|
+
plus a `claims: dict` escape hatch for any claim by its raw snake_case name.
|
|
206
|
+
`create_byob_token` replaces `disks` with `byob_disks` (a map of name →
|
|
207
|
+
S3-compatible/SFTP config) and does not take `max_storage_mb`/`max_files`
|
|
208
|
+
(matching the core), but accepts the same `role`/`edition`/per-tenant kwargs.
|
|
209
|
+
|
|
210
|
+
## Compatibility
|
|
211
|
+
|
|
212
|
+
Tokens and BYOB blobs are validated against the PHP core in CI: a
|
|
213
|
+
Python-minted token decodes in `JwtCompat::decode`, and BYOB credentials
|
|
214
|
+
round-trip both ways through `CredentialEncryptor` (HS256 + HKDF-SHA256 +
|
|
215
|
+
AES-256-GCM). Always mint tokens **on the server** — never ship
|
|
216
|
+
`FLUXFILES_SECRET` to the browser.
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
221
|
+
|
|
222
|
+
## Links
|
|
223
|
+
|
|
224
|
+
- Main repository: `https://github.com/thai-pc/fluxfiles`
|
|
225
|
+
- Documentation: `https://github.com/thai-pc/fluxfiles#python-server-side-token-sdk`
|
|
226
|
+
- Issues: `https://github.com/thai-pc/fluxfiles/issues`
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
fluxfiles_token/__init__.py,sha256=FW_9WbTp-0rXc7lLezklHcMWsltloPKdheO8b4uteWw,995
|
|
2
|
+
fluxfiles_token/crypto.py,sha256=ygWDZqqO5EOWc-oe4Kb0wwRtz52sxDl_wNIolXXuFSM,3178
|
|
3
|
+
fluxfiles_token/exceptions.py,sha256=hMDdMEq_OUj4n9mcf_ujo6-1EB0A-8hhualEMV61yPc,274
|
|
4
|
+
fluxfiles_token/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
fluxfiles_token/token.py,sha256=HeI2QM8f-8ig4Jef6HAitwsaAk1STLvK5RV_B_4z8XU,35220
|
|
6
|
+
fluxfiles_token/types.py,sha256=WCIVW909VThgg8riZuxXdFj8YXVkaZJfG-WhnXyMUmw,2120
|
|
7
|
+
fluxfiles_token/verify.py,sha256=70ZQc91Yh1WPVLoA020xgGpfth-Y2ZJR5NZCx1IQRX4,1267
|
|
8
|
+
fluxfiles_token-0.1.0.dist-info/METADATA,sha256=qT1p3Xs82SRJhpsI5DRRMTLBbbxPBjNiPYBg3IEJ5qY,8170
|
|
9
|
+
fluxfiles_token-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
fluxfiles_token-0.1.0.dist-info/licenses/LICENSE,sha256=FzCo64Ro21dj3cjKvwHiyGOxF0QsnZg_i1TYK2JMlBs,1064
|
|
11
|
+
fluxfiles_token-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thai-pc
|
|
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.
|