shareddata 6.83.17__tar.gz → 6.83.22__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.
- shareddata-6.83.22/MANIFEST.in +3 -0
- {shareddata-6.83.17/src/shareddata.egg-info → shareddata-6.83.22}/PKG-INFO +2 -3
- {shareddata-6.83.17 → shareddata-6.83.22}/setup.py +2 -2
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/__init__.py +3 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/constants.py +7 -0
- shareddata-6.83.22/src/SharedData/API/download_paths.py +126 -0
- shareddata-6.83.22/src/SharedData/API/download_slots.py +142 -0
- shareddata-6.83.22/src/SharedData/API/routes/downloads.py +147 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Defaults.py +30 -5
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ClientAPI.py +63 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/WorkerLib.py +1 -1
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/SharedNumpy.py +128 -51
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Table.py +1 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TimeSeriesDisk.py +1 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TimeseriesContainer.py +1 -0
- {shareddata-6.83.17 → shareddata-6.83.22/src/shareddata.egg-info}/PKG-INFO +2 -3
- {shareddata-6.83.17 → shareddata-6.83.22}/src/shareddata.egg-info/SOURCES.txt +4 -28
- shareddata-6.83.17/MANIFEST.in +0 -1
- shareddata-6.83.17/tests/test_api_collection.py +0 -119
- shareddata-6.83.17/tests/test_api_collection_loopback.py +0 -164
- shareddata-6.83.17/tests/test_api_table.py +0 -201
- shareddata-6.83.17/tests/test_api_table_production_flows.py +0 -414
- shareddata-6.83.17/tests/test_api_table_schemaless.py +0 -261
- shareddata-6.83.17/tests/test_api_table_schemaless_extend.py +0 -131
- shareddata-6.83.17/tests/test_bson_last_pos_reuse.py +0 -468
- shareddata-6.83.17/tests/test_cache_redis.py +0 -162
- shareddata-6.83.17/tests/test_extend_rt.py +0 -69
- shareddata-6.83.17/tests/test_listcache.py +0 -94
- shareddata-6.83.17/tests/test_loc.py +0 -149
- shareddata-6.83.17/tests/test_logger_thread_safety.py +0 -47
- shareddata-6.83.17/tests/test_metadata.py +0 -0
- shareddata-6.83.17/tests/test_mount_unmount_detection.py +0 -93
- shareddata-6.83.17/tests/test_read_write_tail.py +0 -48
- shareddata-6.83.17/tests/test_schema_acquire_timeout.py +0 -47
- shareddata-6.83.17/tests/test_stream_loopback_async.py +0 -169
- shareddata-6.83.17/tests/test_table_auto_grow_all_pkeys.py +0 -98
- shareddata-6.83.17/tests/test_timeseries.py +0 -1137
- shareddata-6.83.17/tests/test_timeseries_api.py +0 -391
- shareddata-6.83.17/tests/test_upsert_unordered_get_date_loc_d1.py +0 -80
- shareddata-6.83.17/tests/test_upsert_unordered_get_date_loc_m1.py +0 -46
- shareddata-6.83.17/tests/test_upsert_unordered_get_date_loc_m15.py +0 -46
- shareddata-6.83.17/tests/test_upsert_unordered_positions_m1.py +0 -47
- shareddata-6.83.17/tests/test_workerpool_jobs_status.py +0 -283
- shareddata-6.83.17/tests/test_workerpool_mutex_release.py +0 -55
- shareddata-6.83.17/tests/test_workerpool_post_commands.py +0 -146
- {shareddata-6.83.17 → shareddata-6.83.22}/LICENSE +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/README.md +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/pyproject.toml +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/setup.cfg +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/ServerGunicorn.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/ServerWaitress.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/auth.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/listcache.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/__init__.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/cache.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/collections.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/metadata.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/system.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/tables.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/timeseries.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/workers.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/utils.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/CacheRedis.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/CollectionMongoDB.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Database.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/AWSEC2.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/AWSS3.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/AutoDocstrings.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ClientSocket.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ClientWebSocket.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/LogHandlerAPI.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/MongoDBClient.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/SaveTables.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ServerSocket.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ServerWebSocket.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/StreamsCache.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/StreamsPersist.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/SyncTable.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/TunnelWebSocket.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/__init__.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Logger.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Metadata.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/MultiProc.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/OpenFIGI.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/ArchiveOldCollections.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/BatchJob.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/Schedule.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/Scheduler.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/UpdateActiveJobs.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/Worker.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/WorkerPool.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/__init__.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/SharedData.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/StreamKafka.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Symbol.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableDisk.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndex.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitFunctions.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitGenerate.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitHash.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitLoc.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Users.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Utils.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/__init__.py +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/sharedmutexwin.pyd +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/shareddata.egg-info/dependency_links.txt +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/shareddata.egg-info/requires.txt +0 -0
- {shareddata-6.83.17 → shareddata-6.83.22}/src/shareddata.egg-info/top_level.txt +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shareddata
|
|
3
|
-
Version: 6.83.
|
|
3
|
+
Version: 6.83.22
|
|
4
4
|
Summary: Memory Mapped / Shared Memory Database with S3 repository
|
|
5
|
-
License: MIT
|
|
6
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
7
|
Classifier: Operating System :: OS Independent
|
|
8
8
|
Requires-Python: >=3.9, <3.13
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
@@ -39,7 +39,6 @@ Requires-Dist: redis==6.2.0
|
|
|
39
39
|
Dynamic: classifier
|
|
40
40
|
Dynamic: description
|
|
41
41
|
Dynamic: description-content-type
|
|
42
|
-
Dynamic: license
|
|
43
42
|
Dynamic: license-file
|
|
44
43
|
Dynamic: requires-dist
|
|
45
44
|
Dynamic: requires-python
|
|
@@ -34,13 +34,13 @@ install_requires = [
|
|
|
34
34
|
|
|
35
35
|
setup(
|
|
36
36
|
name='shareddata',
|
|
37
|
-
version='6.83.
|
|
37
|
+
version='6.83.22',
|
|
38
38
|
description='Memory Mapped / Shared Memory Database with S3 repository',
|
|
39
|
-
license='MIT',
|
|
40
39
|
long_description=open('README.md').read(),
|
|
41
40
|
long_description_content_type='text/markdown',
|
|
42
41
|
classifiers=[
|
|
43
42
|
'Programming Language :: Python :: 3',
|
|
43
|
+
'License :: OSI Approved :: MIT License',
|
|
44
44
|
'Operating System :: OS Independent',
|
|
45
45
|
],
|
|
46
46
|
packages=find_packages(where='src'),
|
|
@@ -21,6 +21,7 @@ from SharedData.API.routes.timeseries import timeseries_bp, init_timeseries_rout
|
|
|
21
21
|
from SharedData.API.routes.workers import workers_bp, init_workers_routes
|
|
22
22
|
from SharedData.API.routes.metadata import metadata_bp, init_metadata_routes
|
|
23
23
|
from SharedData.API.routes.cache import cache_bp, init_cache_routes
|
|
24
|
+
from SharedData.API.routes.downloads import init_downloads_routes
|
|
24
25
|
from SharedData.API.routes.system import (
|
|
25
26
|
system_bp,
|
|
26
27
|
init_system_routes,
|
|
@@ -85,6 +86,7 @@ def create_app(shdata=None, quiet=True):
|
|
|
85
86
|
init_workers_routes(shdata, authenticate_fn)
|
|
86
87
|
init_metadata_routes(shdata, authenticate_fn)
|
|
87
88
|
init_cache_routes(shdata, authenticate_fn)
|
|
89
|
+
downloads_bp = init_downloads_routes(shdata, authenticate_fn)
|
|
88
90
|
init_system_routes(shdata, authenticate_fn)
|
|
89
91
|
|
|
90
92
|
# Register blueprints with /api prefix
|
|
@@ -94,6 +96,7 @@ def create_app(shdata=None, quiet=True):
|
|
|
94
96
|
app.register_blueprint(workers_bp, url_prefix='/api')
|
|
95
97
|
app.register_blueprint(metadata_bp, url_prefix='/api')
|
|
96
98
|
app.register_blueprint(cache_bp, url_prefix='/api')
|
|
99
|
+
app.register_blueprint(downloads_bp, url_prefix='/api')
|
|
97
100
|
app.register_blueprint(system_bp, url_prefix='/api')
|
|
98
101
|
|
|
99
102
|
# Register request lifecycle hooks
|
|
@@ -29,3 +29,10 @@ FORMAT_JSON = 'json'
|
|
|
29
29
|
FORMAT_CSV = 'csv'
|
|
30
30
|
FORMAT_BIN = 'bin'
|
|
31
31
|
FORMAT_BSON = 'bson'
|
|
32
|
+
|
|
33
|
+
# Downloads explorer (/api/list, /api/download)
|
|
34
|
+
DOWNLOAD_SLOT_LIMIT = 5
|
|
35
|
+
DOWNLOAD_SLOT_TTL_SECONDS = 6 * 60 * 60 # leak-safety if a worker dies mid-transfer
|
|
36
|
+
LIST_DEFAULT_DEPTH = 1
|
|
37
|
+
LIST_MAX_DEPTH = 3
|
|
38
|
+
LIST_MAX_ENTRIES = 5000
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path safety and directory listing for the DOWNLOADS_FOLDER explorer.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from SharedData.API.constants import LIST_MAX_ENTRIES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PathEscapeError(ValueError):
|
|
14
|
+
"""Requested path is outside DOWNLOADS_FOLDER."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DownloadsRootError(RuntimeError):
|
|
18
|
+
"""DOWNLOADS_FOLDER is missing or not a directory."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def downloads_root() -> Path:
|
|
22
|
+
raw = os.environ.get('DOWNLOADS_FOLDER')
|
|
23
|
+
if not raw:
|
|
24
|
+
raise DownloadsRootError('DOWNLOADS_FOLDER is not set')
|
|
25
|
+
root = Path(raw).resolve()
|
|
26
|
+
if not root.is_dir():
|
|
27
|
+
raise DownloadsRootError(f'DOWNLOADS_FOLDER is not a directory: {root}')
|
|
28
|
+
return root
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_under_downloads(relpath: str | None) -> Path:
|
|
32
|
+
"""
|
|
33
|
+
Resolve a client-supplied relative path so it stays inside DOWNLOADS_FOLDER.
|
|
34
|
+
|
|
35
|
+
Leading slashes are stripped. ``.``, empty, and ``/`` mean the folder root.
|
|
36
|
+
"""
|
|
37
|
+
root = downloads_root()
|
|
38
|
+
rel = (relpath or '').strip()
|
|
39
|
+
if rel in ('', '.', '/'):
|
|
40
|
+
return root
|
|
41
|
+
if '\x00' in rel:
|
|
42
|
+
raise PathEscapeError('invalid path')
|
|
43
|
+
rel = rel.lstrip('/')
|
|
44
|
+
candidate = (root / rel).resolve()
|
|
45
|
+
try:
|
|
46
|
+
candidate.relative_to(root)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise PathEscapeError('path escapes DOWNLOADS_FOLDER') from exc
|
|
49
|
+
return candidate
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _relative_posix(path: Path, root: Path) -> str:
|
|
53
|
+
if path == root:
|
|
54
|
+
return ''
|
|
55
|
+
return path.relative_to(root).as_posix()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _skip_name(name: str) -> bool:
|
|
59
|
+
return name.startswith('.')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def list_entries(
|
|
63
|
+
relpath: str | None,
|
|
64
|
+
depth: int,
|
|
65
|
+
max_entries: int = LIST_MAX_ENTRIES,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""
|
|
68
|
+
List files and directories under ``relpath`` up to ``depth`` levels.
|
|
69
|
+
|
|
70
|
+
``depth=1`` is the requested directory's immediate children only.
|
|
71
|
+
Hidden names (leading ``.``) are omitted. Escaping symlinks are skipped.
|
|
72
|
+
"""
|
|
73
|
+
root = downloads_root()
|
|
74
|
+
base = resolve_under_downloads(relpath)
|
|
75
|
+
if not base.exists():
|
|
76
|
+
raise FileNotFoundError(f'path not found: {relpath}')
|
|
77
|
+
if not base.is_dir():
|
|
78
|
+
raise NotADirectoryError(f'not a directory: {relpath}')
|
|
79
|
+
|
|
80
|
+
entries: list[dict[str, Any]] = []
|
|
81
|
+
truncated = False
|
|
82
|
+
|
|
83
|
+
def walk(dirpath: Path, remaining: int) -> None:
|
|
84
|
+
nonlocal truncated
|
|
85
|
+
if truncated:
|
|
86
|
+
return
|
|
87
|
+
try:
|
|
88
|
+
children = sorted(dirpath.iterdir(), key=lambda p: p.name.lower())
|
|
89
|
+
except OSError:
|
|
90
|
+
return
|
|
91
|
+
for child in children:
|
|
92
|
+
if truncated:
|
|
93
|
+
return
|
|
94
|
+
if _skip_name(child.name):
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
resolved = child.resolve()
|
|
98
|
+
resolved.relative_to(root)
|
|
99
|
+
except (OSError, ValueError):
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
st = child.stat()
|
|
103
|
+
except OSError:
|
|
104
|
+
continue
|
|
105
|
+
is_dir = child.is_dir()
|
|
106
|
+
entries.append({
|
|
107
|
+
'name': child.name,
|
|
108
|
+
'path': _relative_posix(resolved if not is_dir else child.resolve(), root),
|
|
109
|
+
'type': 'dir' if is_dir else 'file',
|
|
110
|
+
'size': None if is_dir else int(st.st_size),
|
|
111
|
+
'mtime': int(st.st_mtime),
|
|
112
|
+
})
|
|
113
|
+
if len(entries) >= max_entries:
|
|
114
|
+
truncated = True
|
|
115
|
+
return
|
|
116
|
+
if is_dir and remaining > 1:
|
|
117
|
+
walk(child, remaining - 1)
|
|
118
|
+
|
|
119
|
+
walk(base, depth)
|
|
120
|
+
return {
|
|
121
|
+
'path': _relative_posix(base, root),
|
|
122
|
+
'depth': depth,
|
|
123
|
+
'truncated': truncated,
|
|
124
|
+
'count': len(entries),
|
|
125
|
+
'entries': entries,
|
|
126
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Global concurrent-download limiter.
|
|
3
|
+
|
|
4
|
+
Gunicorn runs multiple worker processes (production: 8), so an in-memory
|
|
5
|
+
counter would allow limit × workers. This limiter is process- and host-safe:
|
|
6
|
+
|
|
7
|
+
- Redis (default when REDIS_CLUSTER_NODES is set): one counter for the
|
|
8
|
+
whole SharedData deployment.
|
|
9
|
+
- File lock under DOWNLOADS_FOLDER: fallback for tests / Redis-unavailable,
|
|
10
|
+
still global across hosts that share that NFS folder.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from filelock import FileLock
|
|
18
|
+
|
|
19
|
+
from SharedData.API.constants import DOWNLOAD_SLOT_LIMIT, DOWNLOAD_SLOT_TTL_SECONDS
|
|
20
|
+
from SharedData.API.download_paths import downloads_root
|
|
21
|
+
from SharedData.Logger import Logger
|
|
22
|
+
|
|
23
|
+
_REDIS_SLOT_KEY = '{shareddata}:download_slots'
|
|
24
|
+
|
|
25
|
+
_ACQUIRE_LUA = r"""
|
|
26
|
+
local n = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
27
|
+
if n >= tonumber(ARGV[1]) then
|
|
28
|
+
return -1
|
|
29
|
+
end
|
|
30
|
+
n = redis.call('INCR', KEYS[1])
|
|
31
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
|
|
32
|
+
return n
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_RELEASE_LUA = r"""
|
|
36
|
+
local n = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
37
|
+
if n <= 0 then
|
|
38
|
+
redis.call('SET', KEYS[1], 0)
|
|
39
|
+
return 0
|
|
40
|
+
end
|
|
41
|
+
return redis.call('DECR', KEYS[1])
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_limiter = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DownloadSlotLimiter:
|
|
48
|
+
def acquire(self) -> bool:
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def release(self) -> None:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def active(self) -> int:
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FileDownloadSlotLimiter(DownloadSlotLimiter):
|
|
59
|
+
def __init__(self, root: Path | None = None, limit: int = DOWNLOAD_SLOT_LIMIT):
|
|
60
|
+
self.limit = limit
|
|
61
|
+
self.root = root if root is not None else downloads_root()
|
|
62
|
+
self.count_path = self.root / '.shareddata_download_slots.count'
|
|
63
|
+
self.lock_path = str(self.root / '.shareddata_download_slots.lock')
|
|
64
|
+
|
|
65
|
+
def _read(self) -> int:
|
|
66
|
+
try:
|
|
67
|
+
return max(0, int(self.count_path.read_text().strip() or '0'))
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
def acquire(self) -> bool:
|
|
72
|
+
with FileLock(self.lock_path):
|
|
73
|
+
n = self._read()
|
|
74
|
+
if n >= self.limit:
|
|
75
|
+
return False
|
|
76
|
+
self.count_path.write_text(str(n + 1))
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
def release(self) -> None:
|
|
80
|
+
with FileLock(self.lock_path):
|
|
81
|
+
n = self._read()
|
|
82
|
+
self.count_path.write_text(str(max(0, n - 1)))
|
|
83
|
+
|
|
84
|
+
def active(self) -> int:
|
|
85
|
+
with FileLock(self.lock_path):
|
|
86
|
+
return self._read()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RedisDownloadSlotLimiter(DownloadSlotLimiter):
|
|
90
|
+
def __init__(self, limit: int = DOWNLOAD_SLOT_LIMIT, ttl: int = DOWNLOAD_SLOT_TTL_SECONDS):
|
|
91
|
+
from SharedData.CacheRedis import _build_sync_client
|
|
92
|
+
|
|
93
|
+
self.limit = limit
|
|
94
|
+
self.ttl = ttl
|
|
95
|
+
self._client = _build_sync_client()
|
|
96
|
+
self._acquire = self._client.register_script(_ACQUIRE_LUA)
|
|
97
|
+
self._release = self._client.register_script(_RELEASE_LUA)
|
|
98
|
+
|
|
99
|
+
def acquire(self) -> bool:
|
|
100
|
+
result = self._acquire(keys=[_REDIS_SLOT_KEY], args=[self.limit, self.ttl])
|
|
101
|
+
return int(result) > 0
|
|
102
|
+
|
|
103
|
+
def release(self) -> None:
|
|
104
|
+
self._release(keys=[_REDIS_SLOT_KEY], args=[])
|
|
105
|
+
|
|
106
|
+
def active(self) -> int:
|
|
107
|
+
raw = self._client.get(_REDIS_SLOT_KEY)
|
|
108
|
+
if raw is None:
|
|
109
|
+
return 0
|
|
110
|
+
try:
|
|
111
|
+
return max(0, int(raw))
|
|
112
|
+
except (TypeError, ValueError):
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _make_limiter() -> DownloadSlotLimiter:
|
|
117
|
+
backend = os.environ.get('SHAREDDATA_DOWNLOAD_SLOT_BACKEND', '').strip().lower()
|
|
118
|
+
if backend == 'file':
|
|
119
|
+
return FileDownloadSlotLimiter()
|
|
120
|
+
if backend == 'redis' or os.environ.get('REDIS_CLUSTER_NODES'):
|
|
121
|
+
try:
|
|
122
|
+
return RedisDownloadSlotLimiter()
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
Logger.log.warning(
|
|
125
|
+
'download slot limiter: Redis unavailable (%s); using file backend',
|
|
126
|
+
exc,
|
|
127
|
+
)
|
|
128
|
+
return FileDownloadSlotLimiter()
|
|
129
|
+
return FileDownloadSlotLimiter()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_limiter() -> DownloadSlotLimiter:
|
|
133
|
+
global _limiter
|
|
134
|
+
if _limiter is None:
|
|
135
|
+
_limiter = _make_limiter()
|
|
136
|
+
return _limiter
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def reset_limiter() -> None:
|
|
140
|
+
"""Drop the cached limiter (tests only)."""
|
|
141
|
+
global _limiter
|
|
142
|
+
_limiter = None
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Downloads explorer blueprint: GET /api/list and GET /api/download.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from flask import Blueprint, Response, jsonify, request, stream_with_context
|
|
9
|
+
|
|
10
|
+
from SharedData.API.constants import (
|
|
11
|
+
DOWNLOAD_SLOT_LIMIT,
|
|
12
|
+
ERROR_SLEEP_SECONDS,
|
|
13
|
+
LIST_DEFAULT_DEPTH,
|
|
14
|
+
LIST_MAX_DEPTH,
|
|
15
|
+
)
|
|
16
|
+
from SharedData.API.download_paths import (
|
|
17
|
+
DownloadsRootError,
|
|
18
|
+
PathEscapeError,
|
|
19
|
+
list_entries,
|
|
20
|
+
resolve_under_downloads,
|
|
21
|
+
)
|
|
22
|
+
from SharedData.API.download_slots import get_limiter
|
|
23
|
+
from SharedData.API.utils import make_error_response, make_json_response, parse_int_param
|
|
24
|
+
from SharedData.Logger import Logger
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _clamp_depth(raw: str | None) -> int:
|
|
28
|
+
depth = parse_int_param(raw, LIST_DEFAULT_DEPTH)
|
|
29
|
+
if depth < 1:
|
|
30
|
+
return 1
|
|
31
|
+
if depth > LIST_MAX_DEPTH:
|
|
32
|
+
return LIST_MAX_DEPTH
|
|
33
|
+
return depth
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def init_downloads_routes(shdata, authenticate_fn):
|
|
37
|
+
"""
|
|
38
|
+
Initialize downloads routes with required dependencies.
|
|
39
|
+
|
|
40
|
+
Returns a fresh blueprint so create_app / tests can register it more
|
|
41
|
+
than once in the same process.
|
|
42
|
+
|
|
43
|
+
Parameters:
|
|
44
|
+
shdata: SharedData instance.
|
|
45
|
+
authenticate_fn: Authentication function.
|
|
46
|
+
"""
|
|
47
|
+
downloads_bp = Blueprint('downloads', __name__)
|
|
48
|
+
|
|
49
|
+
@downloads_bp.route('/list', methods=['GET'])
|
|
50
|
+
def list_downloads():
|
|
51
|
+
"""GET /api/list — list files under DOWNLOADS_FOLDER up to a depth cap."""
|
|
52
|
+
try:
|
|
53
|
+
if not authenticate_fn(request, shdata):
|
|
54
|
+
return jsonify({'error': 'unauthorized'}), 401
|
|
55
|
+
|
|
56
|
+
depth = _clamp_depth(request.args.get('depth'))
|
|
57
|
+
payload = list_entries(request.args.get('path'), depth)
|
|
58
|
+
return make_json_response(payload)
|
|
59
|
+
except PathEscapeError as e:
|
|
60
|
+
return make_json_response({'error': str(e)}, status=400)
|
|
61
|
+
except DownloadsRootError as e:
|
|
62
|
+
return make_json_response({'error': str(e)}, status=503)
|
|
63
|
+
except FileNotFoundError as e:
|
|
64
|
+
return make_json_response({'error': str(e)}, status=404)
|
|
65
|
+
except NotADirectoryError as e:
|
|
66
|
+
return make_json_response({'error': str(e)}, status=400)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
Logger.log.error(f'list_downloads error: {e}')
|
|
69
|
+
time.sleep(ERROR_SLEEP_SECONDS)
|
|
70
|
+
return make_error_response(e)
|
|
71
|
+
|
|
72
|
+
@downloads_bp.route('/download', methods=['GET'])
|
|
73
|
+
def download_file():
|
|
74
|
+
"""GET /api/download — stream one regular file; 429 if 5 transfers are active."""
|
|
75
|
+
try:
|
|
76
|
+
if not authenticate_fn(request, shdata):
|
|
77
|
+
return jsonify({'error': 'unauthorized'}), 401
|
|
78
|
+
|
|
79
|
+
target = resolve_under_downloads(request.args.get('path'))
|
|
80
|
+
if not target.exists():
|
|
81
|
+
return make_json_response(
|
|
82
|
+
{'error': f'path not found: {request.args.get("path")}'},
|
|
83
|
+
status=404,
|
|
84
|
+
)
|
|
85
|
+
if not target.is_file():
|
|
86
|
+
return make_json_response(
|
|
87
|
+
{'error': 'not a file'},
|
|
88
|
+
status=400,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
limiter = get_limiter()
|
|
92
|
+
if not limiter.acquire():
|
|
93
|
+
return make_json_response(
|
|
94
|
+
{
|
|
95
|
+
'error': 'download limit reached',
|
|
96
|
+
'limit': DOWNLOAD_SLOT_LIMIT,
|
|
97
|
+
'active': limiter.active(),
|
|
98
|
+
},
|
|
99
|
+
status=429,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
released = False
|
|
103
|
+
|
|
104
|
+
def _release_once():
|
|
105
|
+
nonlocal released
|
|
106
|
+
if released:
|
|
107
|
+
return
|
|
108
|
+
released = True
|
|
109
|
+
limiter.release()
|
|
110
|
+
|
|
111
|
+
def _stream():
|
|
112
|
+
try:
|
|
113
|
+
with target.open('rb') as fh:
|
|
114
|
+
while True:
|
|
115
|
+
chunk = fh.read(1024 * 1024)
|
|
116
|
+
if not chunk:
|
|
117
|
+
break
|
|
118
|
+
yield chunk
|
|
119
|
+
finally:
|
|
120
|
+
_release_once()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
response = Response(
|
|
124
|
+
stream_with_context(_stream()),
|
|
125
|
+
mimetype='application/octet-stream',
|
|
126
|
+
direct_passthrough=True,
|
|
127
|
+
)
|
|
128
|
+
response.headers['Content-Disposition'] = (
|
|
129
|
+
f'attachment; filename="{target.name}"'
|
|
130
|
+
)
|
|
131
|
+
response.headers['Content-Length'] = str(target.stat().st_size)
|
|
132
|
+
response.call_on_close(_release_once)
|
|
133
|
+
except Exception:
|
|
134
|
+
_release_once()
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
return response
|
|
138
|
+
except PathEscapeError as e:
|
|
139
|
+
return make_json_response({'error': str(e)}, status=400)
|
|
140
|
+
except DownloadsRootError as e:
|
|
141
|
+
return make_json_response({'error': str(e)}, status=503)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
Logger.log.error(f'download_file error: {e}')
|
|
144
|
+
time.sleep(ERROR_SLEEP_SECONDS)
|
|
145
|
+
return make_error_response(e)
|
|
146
|
+
|
|
147
|
+
return downloads_bp
|
|
@@ -221,6 +221,27 @@ def _classify_on_mounted_device(resolved_path, current_mounts, declared_mounts):
|
|
|
221
221
|
return True
|
|
222
222
|
|
|
223
223
|
|
|
224
|
+
def _windows_folder_is_live(folder: str) -> bool:
|
|
225
|
+
"""Windows has no fstab/mountinfo stub semantics; an existing dir is live."""
|
|
226
|
+
try:
|
|
227
|
+
return bool(folder) and Path(folder).is_dir()
|
|
228
|
+
except (OSError, TypeError):
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _is_filesystem_root(current) -> bool:
|
|
233
|
+
"""True for POSIX ``/``, Windows drive roots (``C:\\``), and UNC share roots.
|
|
234
|
+
|
|
235
|
+
``Path('C:\\\\').parent`` is itself and ``os.path.ismount('C:\\\\')`` is
|
|
236
|
+
True, so the st_dev==parent_dev stub heuristic must not treat a drive
|
|
237
|
+
letter as an unmounted mountpoint.
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
return current == current.parent or current == type(current)(current.anchor)
|
|
241
|
+
except (OSError, TypeError, ValueError):
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
|
|
224
245
|
def _is_on_mounted_device_legacy(folder: str) -> bool:
|
|
225
246
|
"""Original ``os.path.ismount()`` st_dev heuristic — fallback for platforms
|
|
226
247
|
without ``/proc/self/mountinfo``. Cannot detect an unmounted mountpoint
|
|
@@ -232,20 +253,20 @@ def _is_on_mounted_device_legacy(folder: str) -> bool:
|
|
|
232
253
|
while True:
|
|
233
254
|
if os.path.ismount(current):
|
|
234
255
|
try:
|
|
256
|
+
# Drive / UNC / POSIX root is a live filesystem, not a stub.
|
|
257
|
+
if _is_filesystem_root(current):
|
|
258
|
+
return True
|
|
235
259
|
dev = current.stat().st_dev
|
|
236
260
|
parent_dev = current.parent.stat().st_dev
|
|
237
261
|
if dev != parent_dev:
|
|
238
262
|
return True # device is mounted
|
|
239
263
|
else:
|
|
240
264
|
# Mountpoint stub — device not mounted.
|
|
241
|
-
# But if this is /, it's the root fs (always live).
|
|
242
|
-
if current == Path('/'):
|
|
243
|
-
return True
|
|
244
265
|
return False
|
|
245
266
|
except OSError:
|
|
246
267
|
return False
|
|
247
268
|
if current == current.parent:
|
|
248
|
-
# Reached
|
|
269
|
+
# Reached filesystem root without finding a mountpoint — live.
|
|
249
270
|
return True
|
|
250
271
|
current = current.parent
|
|
251
272
|
except (OSError, TypeError):
|
|
@@ -265,11 +286,15 @@ def _is_on_mounted_device(folder: str) -> bool:
|
|
|
265
286
|
detection (writes silently landing on ``/`` instead of the intended disk).
|
|
266
287
|
|
|
267
288
|
Falls back to the legacy ``os.path.ismount()`` heuristic when mountinfo is
|
|
268
|
-
unavailable (non-Linux).
|
|
289
|
+
unavailable (non-Linux). On Windows there is no fstab/mountinfo stub
|
|
290
|
+
semantics — an existing directory is live (drive roots are not unmounted
|
|
291
|
+
mountpoint stubs).
|
|
269
292
|
"""
|
|
270
293
|
try:
|
|
271
294
|
if not folder:
|
|
272
295
|
return False
|
|
296
|
+
if os.name == 'nt':
|
|
297
|
+
return _windows_folder_is_live(folder)
|
|
273
298
|
resolved = str(Path(folder).resolve())
|
|
274
299
|
current = _current_mount_points()
|
|
275
300
|
if current is None:
|
|
@@ -3054,3 +3054,66 @@ class ClientAPI:
|
|
|
3054
3054
|
response = requests.post(url, params=params)
|
|
3055
3055
|
ClientAPI.raise_for_status(response)
|
|
3056
3056
|
return response.json().get('deleted', 0)
|
|
3057
|
+
|
|
3058
|
+
################# DOWNLOADS #################
|
|
3059
|
+
|
|
3060
|
+
@staticmethod
|
|
3061
|
+
def list_downloads(path: str = '', depth: int = 1,
|
|
3062
|
+
endpoint=None, token=None):
|
|
3063
|
+
"""
|
|
3064
|
+
GET /api/list — list files and folders under DOWNLOADS_FOLDER.
|
|
3065
|
+
|
|
3066
|
+
Parameters:
|
|
3067
|
+
path (str): Relative path under DOWNLOADS_FOLDER. Empty lists the root.
|
|
3068
|
+
depth (int): Directory depth to include (server caps at 3).
|
|
3069
|
+
endpoint (str, optional): API base URL. Defaults to SHAREDDATA_ENDPOINT.
|
|
3070
|
+
token (str, optional): Auth token. Defaults to SHAREDDATA_TOKEN.
|
|
3071
|
+
|
|
3072
|
+
Returns:
|
|
3073
|
+
dict: {path, depth, truncated, count, entries}
|
|
3074
|
+
"""
|
|
3075
|
+
url = os.environ['SHAREDDATA_ENDPOINT'] if not endpoint else endpoint
|
|
3076
|
+
url += '/api/list'
|
|
3077
|
+
params = {'path': path, 'depth': depth}
|
|
3078
|
+
if token is not None:
|
|
3079
|
+
params['token'] = token
|
|
3080
|
+
else:
|
|
3081
|
+
params['token'] = os.environ.get('SHAREDDATA_TOKEN')
|
|
3082
|
+
if not params['token']:
|
|
3083
|
+
raise Exception('SHAREDDATA_TOKEN not found in environment variables')
|
|
3084
|
+
response = requests.get(url, params=params, headers={'Accept': 'application/json'})
|
|
3085
|
+
ClientAPI.raise_for_status(response)
|
|
3086
|
+
return response.json()
|
|
3087
|
+
|
|
3088
|
+
@staticmethod
|
|
3089
|
+
def download_file(path: str, dest, endpoint=None, token=None):
|
|
3090
|
+
"""
|
|
3091
|
+
GET /api/download — stream one file from DOWNLOADS_FOLDER to ``dest``.
|
|
3092
|
+
|
|
3093
|
+
Parameters:
|
|
3094
|
+
path (str): Relative file path under DOWNLOADS_FOLDER.
|
|
3095
|
+
dest: Local filesystem path to write.
|
|
3096
|
+
endpoint (str, optional): API base URL. Defaults to SHAREDDATA_ENDPOINT.
|
|
3097
|
+
token (str, optional): Auth token. Defaults to SHAREDDATA_TOKEN.
|
|
3098
|
+
|
|
3099
|
+
Returns:
|
|
3100
|
+
str: Absolute destination path.
|
|
3101
|
+
"""
|
|
3102
|
+
url = os.environ['SHAREDDATA_ENDPOINT'] if not endpoint else endpoint
|
|
3103
|
+
url += '/api/download'
|
|
3104
|
+
params = {'path': path}
|
|
3105
|
+
if token is not None:
|
|
3106
|
+
params['token'] = token
|
|
3107
|
+
else:
|
|
3108
|
+
params['token'] = os.environ.get('SHAREDDATA_TOKEN')
|
|
3109
|
+
if not params['token']:
|
|
3110
|
+
raise Exception('SHAREDDATA_TOKEN not found in environment variables')
|
|
3111
|
+
response = requests.get(url, params=params, stream=True)
|
|
3112
|
+
ClientAPI.raise_for_status(response)
|
|
3113
|
+
dest_path = os.path.abspath(str(dest))
|
|
3114
|
+
os.makedirs(os.path.dirname(dest_path) or '.', exist_ok=True)
|
|
3115
|
+
with open(dest_path, 'wb') as fh:
|
|
3116
|
+
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
|
3117
|
+
if chunk:
|
|
3118
|
+
fh.write(chunk)
|
|
3119
|
+
return dest_path
|
|
@@ -42,7 +42,7 @@ def get_own_ip():
|
|
|
42
42
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
43
43
|
try:
|
|
44
44
|
# Doesn't need to be reachable, just used to determine the interface
|
|
45
|
-
s.connect(('
|
|
45
|
+
s.connect(('192.0.2.1', 1))
|
|
46
46
|
own_ip = s.getsockname()[0]
|
|
47
47
|
except Exception:
|
|
48
48
|
own_ip = '127.0.0.1'
|