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 CHANGED
@@ -1,4 +1,4 @@
1
- # Lightweight File Storage Service (LFSS)
1
+ # Lite File Storage Service (LFSS)
2
2
  [![PyPI](https://img.shields.io/pypi/v/lfss)](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><password>)`.
36
- 2. `token` query parameter with the value `sha256(<username><password>)`.
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
@@ -1,6 +1,10 @@
1
1
 
2
2
  ## 0.9
3
3
 
4
+ ### 0.9.5
5
+ - Stream bundle path as zip file.
6
+ - Update authentication token hash format (need to reset password).
7
+
4
8
  ### 0.9.4
5
9
  - Decode WebDAV file name.
6
10
  - Allow root-listing for WebDAV.
@@ -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><password>)`.
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, Iterator
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, AsyncIterable, overload
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
- assert not username.startswith('_'), "Error: reserved username"
86
- assert not ('/' in username or len(username) > 255), "Invalid username"
87
- assert urllib.parse.quote(username) == username, "Invalid username, must be URL safe"
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, urls: Optional[list[str]]) -> io.BytesIO:
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, urls):
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((username + password).encode()).hexdigest()
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[_FnReturnT]
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 = 2)
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, FileRecord, UserRecord, AccessLevel,
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
- assert path.endswith("/") or path == ""
85
-
86
- if not path == "" and path[0] == "/": # adapt to both /path and path
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
- # TODO: may check peer users here
90
- owner_records_cache: dict[int, UserRecord] = {} # cache owner records, ID -> UserRecord
91
- async def is_access_granted(file_record: FileRecord):
92
- owner_id = file_record.owner_id
93
- owner = owner_records_cache.get(owner_id, None)
94
- if owner is None:
95
- async with unique_cursor() as conn:
96
- uconn = UserConn(conn)
97
- owner = await uconn.get_user_by_id(owner_id, throw=True)
98
- owner_records_cache[owner_id] = owner
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.4
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
- # Lightweight File Storage Service (LFSS)
25
+ # Lite File Storage Service (LFSS)
25
26
  [![PyPI](https://img.shields.io/pypi/v/lfss)](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><password>)`.
59
- 2. `token` query parameter with the value `sha256(<username><password>)`.
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=JVe9T6N1Rz4hTiiCVoDYe2VB0dAi60VcBgb2twQdfZc,1834
2
- docs/Changelog.md,sha256=3mRHcda4UK8c105XtBfbeTWij0S4xNc-U8JTTPUqCJk,769
3
- docs/Enviroment_variables.md,sha256=LUZF1o70emp-5UPsvXPjcxapP940OqEZzSyyUUT9bEQ,569
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=hHSEEWecKQGZH6oxAmYoG3q7lFfacCbOKVZiUIXT2y8,11819
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=DmnUYMeLOL-45OstysyMpSBPmLofgzvcSrsWjHvssYs,915
32
+ lfss/eng/config.py,sha256=Vni6h52Ce0njVrHZLAWFL8g34YDBdlmGrmRhpxElxQ8,868
33
33
  lfss/eng/connection_pool.py,sha256=4xOF1kXXGqCWeLX5ZVFALKjdY8N1VVAVSSTRfCzbj94,6141
34
- lfss/eng/database.py,sha256=2i8gbh1odOA09tS5VU9cUZy3poZUdCx3XX7UX7umtxw,47188
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=CYEQvPiM28k53hCJBE7N6O6a1xC_wvnP3KZx4DCnD0k,6723
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=BU_DndHW4sYiWUQcTis8iGljmUy8FHfZrzCkE0d1z-Y,6717
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=6yBRJB8_p4RZgDVheDTv1ClBGc3etrQm94j1NiR4FUQ,9349
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.4.dist-info/METADATA,sha256=3wUuwMRn55Z2lnX9wZRGMVxLbfphSLOk1gX01haFaOw,2594
49
- lfss-0.9.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
50
- lfss-0.9.4.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
51
- lfss-0.9.4.dist-info/RECORD,,
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