lfss 0.9.4__py3-none-any.whl → 0.9.5__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.
- Readme.md +3 -3
- docs/Changelog.md +4 -0
- docs/Enviroment_variables.md +1 -1
- lfss/api/connector.py +10 -1
- lfss/eng/config.py +0 -1
- lfss/eng/database.py +41 -7
- lfss/eng/utils.py +3 -3
- lfss/svc/app_base.py +1 -1
- lfss/svc/app_native.py +17 -40
- {lfss-0.9.4.dist-info → lfss-0.9.5.dist-info}/METADATA +5 -4
- {lfss-0.9.4.dist-info → lfss-0.9.5.dist-info}/RECORD +13 -13
- {lfss-0.9.4.dist-info → lfss-0.9.5.dist-info}/WHEEL +0 -0
- {lfss-0.9.4.dist-info → lfss-0.9.5.dist-info}/entry_points.txt +0 -0
Readme.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
#
|
1
|
+
# Lite File Storage Service (LFSS)
|
2
2
|
[](https://pypi.org/project/lfss/)
|
3
3
|
|
4
4
|
My experiment on a lightweight and high-performance file/object storage service...
|
@@ -32,8 +32,8 @@ Or, you can start a web server at `/frontend` and open `index.html` in your brow
|
|
32
32
|
|
33
33
|
The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
|
34
34
|
The authentication can be acheived through one of the following methods:
|
35
|
-
1. `Authorization` header with the value `Bearer sha256(<username
|
36
|
-
2. `token` query parameter with the value `sha256(<username
|
35
|
+
1. `Authorization` header with the value `Bearer sha256(<username>:<password>)`.
|
36
|
+
2. `token` query parameter with the value `sha256(<username>:<password>)`.
|
37
37
|
3. HTTP Basic Authentication with the username and password (If WebDAV is enabled).
|
38
38
|
|
39
39
|
You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
|
docs/Changelog.md
CHANGED
docs/Enviroment_variables.md
CHANGED
@@ -9,4 +9,4 @@
|
|
9
9
|
|
10
10
|
**Client**
|
11
11
|
- `LFSS_ENDPOINT`: The fallback server endpoint. Default is `http://localhost:8000`.
|
12
|
-
- `LFSS_TOKEN`: The fallback token to authenticate. Should be `sha256(<username
|
12
|
+
- `LFSS_TOKEN`: The fallback token to authenticate. Should be `sha256(<username>:<password>)`.
|
lfss/api/connector.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
-
from typing import Optional, Literal
|
2
|
+
from typing import Optional, Literal
|
3
|
+
from collections.abc import Iterator
|
3
4
|
import os
|
4
5
|
import requests
|
5
6
|
import requests.adapters
|
@@ -276,6 +277,14 @@ class Connector:
|
|
276
277
|
self._fetch_factory('POST', '_api/copy', {'src': src, 'dst': dst})(
|
277
278
|
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
278
279
|
)
|
280
|
+
|
281
|
+
def bundle(self, path: str) -> Iterator[bytes]:
|
282
|
+
"""Bundle a path into a zip file."""
|
283
|
+
response = self._fetch_factory('GET', '_api/bundle', {'path': path})(
|
284
|
+
headers = {'Content-Type': 'application/www-form-urlencoded'},
|
285
|
+
stream = True
|
286
|
+
)
|
287
|
+
return response.iter_content(chunk_size=1024)
|
279
288
|
|
280
289
|
def whoami(self) -> UserRecord:
|
281
290
|
"""Gets information about the current user."""
|
lfss/eng/config.py
CHANGED
@@ -19,7 +19,6 @@ if __env_large_file is not None:
|
|
19
19
|
else:
|
20
20
|
LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
|
21
21
|
MAX_MEM_FILE_BYTES = 128 * 1024 * 1024 # 128MB
|
22
|
-
MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
|
23
22
|
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming (on large files)
|
24
23
|
DEBUG_MODE = os.environ.get('LFSS_DEBUG', '0') == '1'
|
25
24
|
|
lfss/eng/database.py
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
|
2
|
-
from typing import Optional, Literal,
|
2
|
+
from typing import Optional, Literal, overload
|
3
|
+
from collections.abc import AsyncIterable
|
3
4
|
from contextlib import asynccontextmanager
|
4
5
|
from abc import ABC
|
5
6
|
|
7
|
+
import uuid, datetime
|
6
8
|
import urllib.parse
|
7
|
-
import uuid
|
8
9
|
import zipfile, io, asyncio
|
9
10
|
|
10
11
|
import aiosqlite, aiofiles
|
@@ -82,9 +83,11 @@ class UserConn(DBObjectBase):
|
|
82
83
|
self, username: str, password: str, is_admin: bool = False,
|
83
84
|
max_storage: int = 1073741824, permission: FileReadPermission = FileReadPermission.UNSET
|
84
85
|
) -> int:
|
85
|
-
|
86
|
-
|
87
|
-
|
86
|
+
def validate_username(username: str):
|
87
|
+
assert not username.startswith('_'), "Error: reserved username"
|
88
|
+
assert not ('/' in username or ':' in username or len(username) > 255), "Invalid username"
|
89
|
+
assert urllib.parse.quote(username) == username, "Invalid username, must be URL safe"
|
90
|
+
validate_username(username)
|
88
91
|
self.logger.debug(f"Creating user {username}")
|
89
92
|
credential = hash_credential(username, password)
|
90
93
|
assert await self.get_user(username) is None, "Duplicate username"
|
@@ -926,14 +929,45 @@ class Database:
|
|
926
929
|
else:
|
927
930
|
blob = await fconn.get_file_blob(f_id)
|
928
931
|
yield r, blob
|
932
|
+
|
933
|
+
async def zip_path_stream(self, top_url: str, op_user: Optional[UserRecord] = None) -> AsyncIterable[bytes]:
|
934
|
+
from stat import S_IFREG
|
935
|
+
from stream_zip import async_stream_zip, ZIP_64
|
936
|
+
if top_url.startswith('/'):
|
937
|
+
top_url = top_url[1:]
|
938
|
+
|
939
|
+
if op_user:
|
940
|
+
if await check_path_permission(top_url, op_user) < AccessLevel.READ:
|
941
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot zip path {top_url}")
|
942
|
+
|
943
|
+
# https://stream-zip.docs.trade.gov.uk/async-interface/
|
944
|
+
async def data_iter():
|
945
|
+
async for (r, blob) in self.iter_path(top_url, None):
|
946
|
+
rel_path = r.url[len(top_url):]
|
947
|
+
rel_path = decode_uri_compnents(rel_path)
|
948
|
+
b_iter: AsyncIterable[bytes]
|
949
|
+
if isinstance(blob, bytes):
|
950
|
+
async def blob_iter(): yield blob
|
951
|
+
b_iter = blob_iter() # type: ignore
|
952
|
+
else:
|
953
|
+
assert isinstance(blob, AsyncIterable)
|
954
|
+
b_iter = blob
|
955
|
+
yield (
|
956
|
+
rel_path,
|
957
|
+
datetime.datetime.now(),
|
958
|
+
S_IFREG | 0o600,
|
959
|
+
ZIP_64,
|
960
|
+
b_iter
|
961
|
+
)
|
962
|
+
return async_stream_zip(data_iter())
|
929
963
|
|
930
964
|
@concurrent_wrap()
|
931
|
-
async def zip_path(self, top_url: str,
|
965
|
+
async def zip_path(self, top_url: str, op_user: Optional[UserRecord]) -> io.BytesIO:
|
932
966
|
if top_url.startswith('/'):
|
933
967
|
top_url = top_url[1:]
|
934
968
|
buffer = io.BytesIO()
|
935
969
|
with zipfile.ZipFile(buffer, 'w') as zf:
|
936
|
-
async for (r, blob) in self.iter_path(top_url,
|
970
|
+
async for (r, blob) in self.iter_path(top_url, None):
|
937
971
|
rel_path = r.url[len(top_url):]
|
938
972
|
rel_path = decode_uri_compnents(rel_path)
|
939
973
|
if r.external:
|
lfss/eng/utils.py
CHANGED
@@ -20,7 +20,7 @@ async def copy_file(source: str|pathlib.Path, destination: str|pathlib.Path):
|
|
20
20
|
await dest.write(chunk)
|
21
21
|
|
22
22
|
def hash_credential(username: str, password: str):
|
23
|
-
return hashlib.sha256(
|
23
|
+
return hashlib.sha256(f"{username}:{password}".encode()).hexdigest()
|
24
24
|
|
25
25
|
def encode_uri_compnents(path: str):
|
26
26
|
path_sp = path.split("/")
|
@@ -155,7 +155,7 @@ def fmt_storage_size(size: int) -> str:
|
|
155
155
|
return f"{size/1024**4:.2f}T"
|
156
156
|
|
157
157
|
_FnReturnT = TypeVar('_FnReturnT')
|
158
|
-
_AsyncReturnT = Awaitable
|
158
|
+
_AsyncReturnT = TypeVar('_AsyncReturnT', bound=Awaitable)
|
159
159
|
_g_executor = None
|
160
160
|
def get_global_executor():
|
161
161
|
global _g_executor
|
@@ -179,7 +179,7 @@ def concurrent_wrap(executor=None):
|
|
179
179
|
def sync_fn(*args, **kwargs):
|
180
180
|
loop = asyncio.new_event_loop()
|
181
181
|
return loop.run_until_complete(func(*args, **kwargs))
|
182
|
-
return sync_fn
|
182
|
+
return sync_fn # type: ignore
|
183
183
|
return _concurrent_wrap
|
184
184
|
|
185
185
|
# https://stackoverflow.com/a/279586/6775765
|
lfss/svc/app_base.py
CHANGED
@@ -27,7 +27,7 @@ req_conn = RequestDB()
|
|
27
27
|
async def lifespan(app: FastAPI):
|
28
28
|
global db
|
29
29
|
try:
|
30
|
-
await global_connection_init(n_read =
|
30
|
+
await global_connection_init(n_read = 8 if not DEBUG_MODE else 1)
|
31
31
|
await asyncio.gather(db.init(), req_conn.init())
|
32
32
|
yield
|
33
33
|
await req_conn.commit()
|
lfss/svc/app_native.py
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
from typing import Optional, Literal
|
2
2
|
|
3
3
|
from fastapi import Depends, Request, Response, UploadFile
|
4
|
+
from fastapi.responses import StreamingResponse
|
4
5
|
from fastapi.exceptions import HTTPException
|
5
6
|
|
6
|
-
from ..eng.config import MAX_BUNDLE_BYTES
|
7
7
|
from ..eng.utils import ensure_uri_compnents
|
8
8
|
from ..eng.connection_pool import unique_cursor
|
9
9
|
from ..eng.database import check_file_read_permission, check_path_permission, UserConn, FileConn
|
10
10
|
from ..eng.datatype import (
|
11
|
-
FileReadPermission,
|
11
|
+
FileReadPermission, UserRecord, AccessLevel,
|
12
12
|
FileSortKey, DirSortKey
|
13
13
|
)
|
14
14
|
|
@@ -81,46 +81,23 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
|
|
81
81
|
async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
|
82
82
|
logger.info(f"GET bundle({path}), user: {user.username}")
|
83
83
|
path = ensure_uri_compnents(path)
|
84
|
-
|
85
|
-
|
86
|
-
if
|
84
|
+
if not path.endswith("/"):
|
85
|
+
raise HTTPException(status_code=400, detail="Path must end with /")
|
86
|
+
if path[0] == "/": # adapt to both /path and path
|
87
87
|
path = path[1:]
|
88
|
+
if path == "":
|
89
|
+
raise HTTPException(status_code=400, detail="Cannot bundle root")
|
88
90
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
allow_access, _ = check_file_read_permission(user, owner, file_record)
|
101
|
-
return allow_access
|
102
|
-
|
103
|
-
async with unique_cursor() as conn:
|
104
|
-
fconn = FileConn(conn)
|
105
|
-
files = await fconn.list_path_files(
|
106
|
-
url = path, flat = True,
|
107
|
-
limit=(await fconn.count_path_files(url = path, flat = True))
|
108
|
-
)
|
109
|
-
files = [f for f in files if await is_access_granted(f)]
|
110
|
-
if len(files) == 0:
|
111
|
-
raise HTTPException(status_code=404, detail="No files found")
|
112
|
-
|
113
|
-
# return bundle of files
|
114
|
-
total_size = sum([f.file_size for f in files])
|
115
|
-
if total_size > MAX_BUNDLE_BYTES:
|
116
|
-
raise HTTPException(status_code=400, detail="Too large to zip")
|
117
|
-
|
118
|
-
file_paths = [f.url for f in files]
|
119
|
-
zip_buffer = await db.zip_path(path, file_paths)
|
120
|
-
return Response(
|
121
|
-
content=zip_buffer.getvalue(), media_type="application/zip", headers={
|
122
|
-
"Content-Disposition": f"attachment; filename=bundle.zip",
|
123
|
-
"Content-Length": str(zip_buffer.getbuffer().nbytes)
|
91
|
+
async with unique_cursor() as cur:
|
92
|
+
dir_record = await FileConn(cur).get_path_record(path)
|
93
|
+
|
94
|
+
pathname = f"{path.split('/')[-2]}"
|
95
|
+
return StreamingResponse(
|
96
|
+
content = await db.zip_path_stream(path, op_user=user),
|
97
|
+
media_type = "application/zip",
|
98
|
+
headers = {
|
99
|
+
f"Content-Disposition": f"attachment; filename=bundle-{pathname}.zip",
|
100
|
+
"X-Content-Bytes": str(dir_record.size),
|
124
101
|
}
|
125
102
|
)
|
126
103
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.9.
|
3
|
+
Version: 0.9.5
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li_mengxun
|
@@ -17,11 +17,12 @@ Requires-Dist: mimesniff (==1.*)
|
|
17
17
|
Requires-Dist: pillow
|
18
18
|
Requires-Dist: python-multipart
|
19
19
|
Requires-Dist: requests (==2.*)
|
20
|
+
Requires-Dist: stream-zip (==0.*)
|
20
21
|
Requires-Dist: uvicorn (==0.*)
|
21
22
|
Project-URL: Repository, https://github.com/MenxLi/lfss
|
22
23
|
Description-Content-Type: text/markdown
|
23
24
|
|
24
|
-
#
|
25
|
+
# Lite File Storage Service (LFSS)
|
25
26
|
[](https://pypi.org/project/lfss/)
|
26
27
|
|
27
28
|
My experiment on a lightweight and high-performance file/object storage service...
|
@@ -55,8 +56,8 @@ Or, you can start a web server at `/frontend` and open `index.html` in your brow
|
|
55
56
|
|
56
57
|
The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
|
57
58
|
The authentication can be acheived through one of the following methods:
|
58
|
-
1. `Authorization` header with the value `Bearer sha256(<username
|
59
|
-
2. `token` query parameter with the value `sha256(<username
|
59
|
+
1. `Authorization` header with the value `Bearer sha256(<username>:<password>)`.
|
60
|
+
2. `token` query parameter with the value `sha256(<username>:<password>)`.
|
60
61
|
3. HTTP Basic Authentication with the username and password (If WebDAV is enabled).
|
61
62
|
|
62
63
|
You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
|
@@ -1,6 +1,6 @@
|
|
1
|
-
Readme.md,sha256=
|
2
|
-
docs/Changelog.md,sha256=
|
3
|
-
docs/Enviroment_variables.md,sha256=
|
1
|
+
Readme.md,sha256=ST2E12DJVlKTReiJjRBc7KZyAr8KyqlcK2BoTc_Peaw,1829
|
2
|
+
docs/Changelog.md,sha256=QYej_hmGnv9t8wjFHXBvmrBOvY7aACZ82oa5SVkIyzM,882
|
3
|
+
docs/Enviroment_variables.md,sha256=xaL8qBwT8B2Qe11FaOU3xWrRCh1mJ1VyTFCeFbkd0rs,570
|
4
4
|
docs/Known_issues.md,sha256=ZqETcWP8lzTOel9b2mxEgCnADFF8IxOrEtiVO1NoMAk,251
|
5
5
|
docs/Permission.md,sha256=mvK8gVBBgoIFJqikcaReU_bUo-mTq_ECqJaDDJoQF7Q,3126
|
6
6
|
docs/Webdav.md,sha256=-Ja-BTWSY1BEMAyZycvEMNnkNTPZ49gSPzmf3Lbib70,1547
|
@@ -19,7 +19,7 @@ frontend/thumb.css,sha256=rNsx766amYS2DajSQNabhpQ92gdTpNoQKmV69OKvtpI,295
|
|
19
19
|
frontend/thumb.js,sha256=46ViD2TlTTWy0fx6wjoAs_5CQ4ajYB90vVzM7UO2IHw,6182
|
20
20
|
frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
|
21
21
|
lfss/api/__init__.py,sha256=8IJqrpWK1doIyVVbntvVic82A57ncwl5b0BRHX4Ri6A,6660
|
22
|
-
lfss/api/connector.py,sha256=
|
22
|
+
lfss/api/connector.py,sha256=0iopAvqUiUJDjbAtAjr9ynmURnmB-Ejg3INL-877s_E,12192
|
23
23
|
lfss/cli/__init__.py,sha256=lPwPmqpa7EXQ4zlU7E7LOe6X2kw_xATGdwoHphUEirA,827
|
24
24
|
lfss/cli/balance.py,sha256=fUbKKAUyaDn74f7mmxMfBL4Q4voyBLHu6Lg_g8GfMOQ,4121
|
25
25
|
lfss/cli/cli.py,sha256=aYjB8d4k6JUd9efxZK-XOj-mlG4JeOr_0lnj2qqCiK0,8066
|
@@ -29,23 +29,23 @@ lfss/cli/user.py,sha256=1mTroQbaKxHjFCPHT67xwd08v-zxH0RZ_OnVc-4MzL0,5364
|
|
29
29
|
lfss/cli/vacuum.py,sha256=GOG72d3NYe9bYCNc3y8JecEmM-DrKlGq3JQcisv_xBg,3702
|
30
30
|
lfss/eng/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
31
31
|
lfss/eng/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
|
32
|
-
lfss/eng/config.py,sha256=
|
32
|
+
lfss/eng/config.py,sha256=Vni6h52Ce0njVrHZLAWFL8g34YDBdlmGrmRhpxElxQ8,868
|
33
33
|
lfss/eng/connection_pool.py,sha256=4xOF1kXXGqCWeLX5ZVFALKjdY8N1VVAVSSTRfCzbj94,6141
|
34
|
-
lfss/eng/database.py,sha256=
|
34
|
+
lfss/eng/database.py,sha256=9KMV-VeD8Dgd6M9RfNfAKwF5gWm763eJpJ2g5zyn_uY,48691
|
35
35
|
lfss/eng/datatype.py,sha256=27UB7-l9SICy5lAvKjdzpTL_GohZjzstQcr9PtAq7nM,2709
|
36
36
|
lfss/eng/error.py,sha256=dAlQHXOnQcSkA2vTugJFSxcyDqoFlPucBoFpTZ7GI6w,654
|
37
37
|
lfss/eng/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
|
38
38
|
lfss/eng/thumb.py,sha256=x9jIHHU1tskmp-TavPPcxGpbmEjCp9gbH6ZlsEfqUxY,3383
|
39
|
-
lfss/eng/utils.py,sha256=
|
39
|
+
lfss/eng/utils.py,sha256=WYoXFFi5308UWtFC8VP792gpzrVbHZZHhP3PaFjxIEY,6770
|
40
40
|
lfss/sql/init.sql,sha256=8LjHx0TBCkBD62xFfssSeHDqKYVQQJkZAg4rSm046f4,1496
|
41
41
|
lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
|
42
42
|
lfss/svc/app.py,sha256=ftWCpepBx-gTSG7i-TB-IdinPPstAYYQjCgnTfeMZeI,219
|
43
|
-
lfss/svc/app_base.py,sha256=
|
43
|
+
lfss/svc/app_base.py,sha256=PgL5MNU3QTwgIJP8CflDi9YBZ-uo4hVf74ADyWTo9yg,6742
|
44
44
|
lfss/svc/app_dav.py,sha256=D0KSgjtTktPjIhyIKG5eRmBdh5X8HYFYH151E6gzlbc,18245
|
45
|
-
lfss/svc/app_native.py,sha256=
|
45
|
+
lfss/svc/app_native.py,sha256=N2cidZ5sIS0p9HOkPqWvpGkqe4bIA7CgGEp4CsvgZ6Q,8347
|
46
46
|
lfss/svc/common_impl.py,sha256=0fjbqHWgqDhLfBEu6aC0Z5qgNt67C7z0Qroj7aV3Iq4,13830
|
47
47
|
lfss/svc/request_log.py,sha256=v8yXEIzPjaksu76Oh5vgdbUEUrw8Kt4etLAXBWSGie8,3207
|
48
|
-
lfss-0.9.
|
49
|
-
lfss-0.9.
|
50
|
-
lfss-0.9.
|
51
|
-
lfss-0.9.
|
48
|
+
lfss-0.9.5.dist-info/METADATA,sha256=fToo-wrY0_XJPbk3cte8hb_4DClyPFv3ZTzJB77G-5w,2623
|
49
|
+
lfss-0.9.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
50
|
+
lfss-0.9.5.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
|
51
|
+
lfss-0.9.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|