lfss 0.9.2__py3-none-any.whl → 0.9.4__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.
docs/Changelog.md ADDED
@@ -0,0 +1,27 @@
1
+
2
+ ## 0.9
3
+
4
+ ### 0.9.4
5
+ - Decode WebDAV file name.
6
+ - Allow root-listing for WebDAV.
7
+ - Always return 207 status code for propfind.
8
+ - Refactor debounce utility.
9
+
10
+ ### 0.9.3
11
+ - Fix empty file getting.
12
+ - HTTP `PUT/POST` default to overwrite the file.
13
+ - Use shared implementations for `PUT`, `GET`, `DELETE` methods.
14
+ - Inherit permission on overwriting `unset` permission files.
15
+
16
+ ### 0.9.2
17
+ - Native copy function.
18
+ - Only enable basic authentication if WebDAV is enabled.
19
+ - `WWW-Authenticate` header is now added to the response when authentication fails.
20
+
21
+ ### 0.9.1
22
+ - Add WebDAV support.
23
+ - Code refactor, use `lfss.eng` and `lfss.svc`.
24
+
25
+ ### 0.9.0
26
+ - User peer access control, now user can share their path with other users.
27
+ - Fix high concurrency database locking on file getting.
docs/Webdav.md CHANGED
@@ -15,8 +15,8 @@ Please note:
15
15
  2. LFSS not allow creating files in the root directory, however some client such as [Finder](https://sabre.io/dav/clients/finder/) will try to create files in the root directory. Thus, it is safer to mount the user directory only, e.g. `http://localhost:8000/<username>/`.
16
16
  3. LFSS not allow directory creation, instead it creates directoy implicitly when a file is uploaded to a non-exist directory.
17
17
  i.e. `PUT http://localhost:8000/<username>/dir/file.txt` will create the `dir` directory if it does not exist.
18
- However, the WebDAV `MKCOL` method requires the directory to be created explicitly, so WebDAV `MKCOL` method instead create a decoy file on the path (`.lfss-keep`), and hide the file from the file listing by `PROPFIND` method.
18
+ However, the WebDAV `MKCOL` method requires the directory to be created explicitly, so WebDAV `MKCOL` method instead create a decoy file on the path (`.lfss_keep`), and hide the file from the file listing by `PROPFIND` method.
19
19
  This leads to:
20
- 1) You may see a `.lfss-keep` file in the directory with native file listing (e.g. `/_api/list-files`), but it is hidden in WebDAV clients.
21
- 2) The directory may be deleted if there is no file in it and the `.lfss-keep` file is not created by WebDAV client.
20
+ 1) You may see a `.lfss_keep` file in the directory with native file listing (e.g. `/_api/list-files`), but it is hidden in WebDAV clients.
21
+ 2) The directory may be deleted if there is no file in it and the `.lfss_keep` file is not created by WebDAV client.
22
22
 
@@ -29,7 +29,7 @@ async def get_connection(read_only: bool = False) -> aiosqlite.Connection:
29
29
 
30
30
  conn = await aiosqlite.connect(
31
31
  get_db_uri(DATA_HOME / 'index.db', read_only=read_only),
32
- timeout = 20, uri = True
32
+ timeout = 10, uri = True
33
33
  )
34
34
  async with conn.cursor() as c:
35
35
  await c.execute(
lfss/eng/database.py CHANGED
@@ -161,7 +161,7 @@ class UserConn(DBObjectBase):
161
161
  async def list_peer_users(self, src_user: int | str, level: AccessLevel) -> list[UserRecord]:
162
162
  """
163
163
  List all users that src_user can do [AliasLevel] to, with level >= level,
164
- Note: the returned list does not include src_user and admin users
164
+ Note: the returned list does not include src_user and is not apporiate for admin (who has all permissions for all users)
165
165
  """
166
166
  assert int(level) > AccessLevel.NONE, f"Invalid level, {level}"
167
167
  match src_user:
@@ -427,8 +427,7 @@ class FileConn(DBObjectBase):
427
427
  await self._user_size_inc(user_id, old.file_size)
428
428
  self.logger.info(f"Copied file {old_url} to {new_url}")
429
429
 
430
- # not tested
431
- async def copy_path(self, old_url: str, new_url: str, conflict_handler: Literal['skip', 'overwrite'] = 'overwrite', user_id: Optional[int] = None):
430
+ async def copy_path(self, old_url: str, new_url: str, user_id: Optional[int] = None):
432
431
  assert old_url.endswith('/'), "Old path must end with /"
433
432
  assert new_url.endswith('/'), "New path must end with /"
434
433
  if user_id is None:
@@ -440,11 +439,8 @@ class FileConn(DBObjectBase):
440
439
  for r in res:
441
440
  old_record = FileRecord(*r)
442
441
  new_r = new_url + old_record.url[len(old_url):]
443
- if conflict_handler == 'overwrite':
444
- await self.cur.execute("DELETE FROM fmeta WHERE url = ?", (new_r, ))
445
- elif conflict_handler == 'skip':
446
- if (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))) is not None:
447
- continue
442
+ if await (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))).fetchone() is not None:
443
+ raise FileExistsError(f"File {new_r} already exists")
448
444
  new_fid = str(uuid.uuid4())
449
445
  user_id = old_record.owner_id if user_id is None else user_id
450
446
  await self.cur.execute(
@@ -456,6 +452,7 @@ class FileConn(DBObjectBase):
456
452
  else:
457
453
  await copy_file(LARGE_BLOB_DIR / old_record.file_id, LARGE_BLOB_DIR / new_fid)
458
454
  await self._user_size_inc(user_id, old_record.file_size)
455
+ self.logger.info(f"Copied path {old_url} to {new_url}")
459
456
 
460
457
  async def move_file(self, old_url: str, new_url: str):
461
458
  old = await self.get_file_record(old_url)
@@ -858,7 +855,7 @@ class Database:
858
855
 
859
856
  async with transaction() as cur:
860
857
  fconn = FileConn(cur)
861
- await fconn.copy_path(old_url, new_url, 'overwrite', op_user.id)
858
+ await fconn.copy_path(old_url, new_url, op_user.id)
862
859
 
863
860
  async def __batch_delete_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
864
861
  # https://github.com/langchain-ai/langchain/issues/10321
lfss/eng/error.py CHANGED
@@ -6,6 +6,10 @@ class FileLockedError(LFSSExceptionBase):...
6
6
 
7
7
  class InvalidOptionsError(LFSSExceptionBase, ValueError):...
8
8
 
9
+ class InvalidDataError(LFSSExceptionBase, ValueError):...
10
+
11
+ class InvalidPathError(LFSSExceptionBase, ValueError):...
12
+
9
13
  class DatabaseLockedError(LFSSExceptionBase, sqlite3.DatabaseError):...
10
14
 
11
15
  class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
@@ -14,8 +18,6 @@ class FileDuplicateError(LFSSExceptionBase, FileExistsError):...
14
18
 
15
19
  class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
16
20
 
17
- class InvalidPathError(LFSSExceptionBase, ValueError):...
18
-
19
21
  class StorageExceededError(LFSSExceptionBase):...
20
22
 
21
23
  class TooManyItemsError(LFSSExceptionBase):...
lfss/eng/thumb.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from lfss.eng.config import THUMB_DB, THUMB_SIZE
2
2
  from lfss.eng.database import FileConn
3
+ from lfss.eng.error import *
3
4
  from lfss.eng.connection_pool import unique_cursor
4
5
  from typing import Optional
5
6
  from PIL import Image
@@ -32,7 +33,10 @@ async def _get_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str) -> Option
32
33
  return blob
33
34
 
34
35
  async def _save_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str, raw_bytes: bytes) -> bytes:
35
- raw_img = Image.open(BytesIO(raw_bytes))
36
+ try:
37
+ raw_img = Image.open(BytesIO(raw_bytes))
38
+ except Exception:
39
+ raise InvalidDataError('Invalid image data for thumbnail: ' + path)
36
40
  raw_img.thumbnail(THUMB_SIZE)
37
41
  img = raw_img.convert('RGB')
38
42
  bio = BytesIO()
lfss/eng/utils.py CHANGED
@@ -36,17 +36,41 @@ def ensure_uri_compnents(path: str):
36
36
  """ Ensure the path components are safe to use """
37
37
  return encode_uri_compnents(decode_uri_compnents(path))
38
38
 
39
- g_debounce_tasks: OrderedDict[str, asyncio.Task] = OrderedDict()
40
- lock_debounce_task_queue = Lock()
39
+ class TaskManager:
40
+ def __init__(self):
41
+ self._tasks: OrderedDict[str, asyncio.Task] = OrderedDict()
42
+
43
+ def push(self, task: asyncio.Task) -> str:
44
+ tid = uuid4().hex
45
+ if tid in self._tasks:
46
+ raise ValueError("Task ID collision")
47
+ self._tasks[tid] = task
48
+ return tid
49
+
50
+ def cancel(self, task_id: str):
51
+ task = self._tasks.pop(task_id, None)
52
+ if task is not None:
53
+ task.cancel()
54
+
55
+ def truncate(self):
56
+ new_tasks = OrderedDict()
57
+ for tid, task in self._tasks.items():
58
+ if not task.done():
59
+ new_tasks[tid] = task
60
+ self._tasks = new_tasks
61
+
62
+ async def wait_all(self):
63
+ async def stop_task(task: asyncio.Task):
64
+ if not task.done():
65
+ await task
66
+ await asyncio.gather(*map(stop_task, self._tasks.values()))
67
+ self._tasks.clear()
68
+
69
+ def __len__(self): return len(self._tasks)
70
+
71
+ g_debounce_tasks: TaskManager = TaskManager()
41
72
  async def wait_for_debounce_tasks():
42
- async def stop_task(task: asyncio.Task):
43
- task.cancel()
44
- try:
45
- await task
46
- except asyncio.CancelledError:
47
- pass
48
- await asyncio.gather(*map(stop_task, g_debounce_tasks.values()))
49
- g_debounce_tasks.clear()
73
+ await g_debounce_tasks.wait_all()
50
74
 
51
75
  def debounce_async(delay: float = 0.1, max_wait: float = 1.):
52
76
  """
@@ -54,7 +78,8 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
54
78
  ensuring execution at least once every `max_wait` seconds.
55
79
  """
56
80
  def debounce_wrap(func):
57
- task_record: tuple[str, asyncio.Task] | None = None
81
+ # task_record: tuple[str, asyncio.Task] | None = None
82
+ prev_task_id = None
58
83
  fn_execution_lock = Lock()
59
84
  last_execution_time = 0
60
85
 
@@ -67,12 +92,11 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
67
92
 
68
93
  @functools.wraps(func)
69
94
  async def wrapper(*args, **kwargs):
70
- nonlocal task_record, last_execution_time
95
+ nonlocal prev_task_id, last_execution_time
71
96
 
72
- async with lock_debounce_task_queue:
73
- if task_record is not None:
74
- task_record[1].cancel()
75
- g_debounce_tasks.pop(task_record[0], None)
97
+ if prev_task_id is not None:
98
+ g_debounce_tasks.cancel(prev_task_id)
99
+ prev_task_id = None
76
100
 
77
101
  async with fn_execution_lock:
78
102
  if time.monotonic() - last_execution_time > max_wait:
@@ -81,14 +105,12 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
81
105
  return
82
106
 
83
107
  task = asyncio.create_task(delayed_func(*args, **kwargs))
84
- task_uid = uuid4().hex
85
- task_record = (task_uid, task)
86
- async with lock_debounce_task_queue:
87
- g_debounce_tasks[task_uid] = task
88
- if len(g_debounce_tasks) > 2048:
89
- # finished tasks are not removed from the dict
90
- # so we need to clear it periodically
91
- await wait_for_debounce_tasks()
108
+ prev_task_id = g_debounce_tasks.push(task)
109
+ if len(g_debounce_tasks) > 1024:
110
+ # finished tasks are not removed from the dict
111
+ # so we need to clear it periodically
112
+ g_debounce_tasks.truncate()
113
+
92
114
  return wrapper
93
115
  return debounce_wrap
94
116
 
lfss/svc/app_base.py CHANGED
@@ -47,13 +47,14 @@ def handle_exception(fn):
47
47
  if isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
48
48
  if isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
49
49
  if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
50
+ if isinstance(e, InvalidOptionsError): raise HTTPException(status_code=400, detail=str(e))
51
+ if isinstance(e, InvalidDataError): raise HTTPException(status_code=400, detail=str(e))
50
52
  if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
51
53
  if isinstance(e, FileDuplicateError): raise HTTPException(status_code=409, detail=str(e))
52
54
  if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
53
55
  if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
54
56
  if isinstance(e, DatabaseLockedError): raise HTTPException(status_code=503, detail=str(e))
55
57
  if isinstance(e, FileLockedError): raise HTTPException(status_code=423, detail=str(e))
56
- if isinstance(e, InvalidOptionsError): raise HTTPException(status_code=400, detail=str(e))
57
58
  logger.error(f"Uncaptured error in {fn.__name__}: {e}")
58
59
  raise
59
60
  return wrapper
lfss/svc/app_dav.py CHANGED
@@ -9,11 +9,11 @@ import xml.etree.ElementTree as ET
9
9
  from ..eng.connection_pool import unique_cursor
10
10
  from ..eng.error import *
11
11
  from ..eng.config import DATA_HOME, DEBUG_MODE
12
- from ..eng.datatype import UserRecord, FileRecord, DirectoryRecord
13
- from ..eng.database import FileConn
12
+ from ..eng.datatype import UserRecord, FileRecord, DirectoryRecord, AccessLevel
13
+ from ..eng.database import FileConn, UserConn, check_path_permission
14
14
  from ..eng.utils import ensure_uri_compnents, decode_uri_compnents, format_last_modified, static_vars
15
15
  from .app_base import *
16
- from .common_impl import get_file_impl, put_file_impl, delete_impl, copy_impl
16
+ from .common_impl import copy_impl
17
17
 
18
18
  LOCK_DB_PATH = DATA_HOME / "lock.db"
19
19
  MKDIR_PLACEHOLDER = ".lfss_keep"
@@ -53,11 +53,27 @@ async def eval_path(path: str) -> tuple[ptype, str, Optional[FileRecord | Direct
53
53
  # path now is url-safe and without leading slash
54
54
  if path.endswith("/"):
55
55
  lfss_path = path
56
- async with unique_cursor() as c:
57
- fconn = FileConn(c)
58
- if await fconn.count_path_files(path, flat=True) == 0:
59
- return None, lfss_path, None
60
- return "dir", lfss_path, await fconn.get_path_record(path)
56
+ dir_path_sp = path.split("/")
57
+ if len(dir_path_sp) > 2:
58
+ async with unique_cursor() as c:
59
+ fconn = FileConn(c)
60
+ if await fconn.count_path_files(path, flat=True) == 0:
61
+ return None, lfss_path, None
62
+ return "dir", lfss_path, await fconn.get_path_record(path)
63
+ else:
64
+ # test if its a user's root directory
65
+ assert len(dir_path_sp) == 2
66
+ username = path.split("/")[0]
67
+ async with unique_cursor() as c:
68
+ uconn = UserConn(c)
69
+ u = await uconn.get_user(username)
70
+ if u is None:
71
+ return None, lfss_path, None
72
+ return "dir", lfss_path, DirectoryRecord(lfss_path)
73
+
74
+ # may be root directory
75
+ if path == "":
76
+ return "dir", "", DirectoryRecord("")
61
77
 
62
78
  # not end with /, check if it is a file
63
79
  async with unique_cursor() as c:
@@ -66,11 +82,10 @@ async def eval_path(path: str) -> tuple[ptype, str, Optional[FileRecord | Direct
66
82
  lfss_path = path
67
83
  return "file", lfss_path, res
68
84
 
69
- if path == "": return "dir", "", DirectoryRecord("")
70
85
  async with unique_cursor() as c:
86
+ lfss_path = path + "/"
71
87
  fconn = FileConn(c)
72
- if await fconn.count_path_files(path + "/") > 0:
73
- lfss_path = path + "/"
88
+ if await fconn.count_path_files(lfss_path) > 0:
74
89
  return "dir", lfss_path, await fconn.get_path_record(lfss_path)
75
90
 
76
91
  return None, path, None
@@ -110,7 +125,7 @@ async def unlock_path(user: UserRecord, p: str, token: str):
110
125
  raise FileLockedError(f"Failed to unlock file [{p}] with token {token}")
111
126
  await cur.execute("DELETE FROM locks WHERE path=?", (p,))
112
127
  await conn.commit()
113
- async def query_lock_el(p: str, top_el_name: str = f"{{{DAV_NS}}}lockinfo") -> Optional[ET.Element]:
128
+ async def query_lock_element(p: str, top_el_name: str = f"{{{DAV_NS}}}lockinfo") -> Optional[ET.Element]:
114
129
  async with aiosqlite.connect(LOCK_DB_PATH) as conn:
115
130
  await conn.execute("BEGIN EXCLUSIVE")
116
131
  await conn.execute(lock_table_create_sql)
@@ -145,15 +160,16 @@ async def create_file_xml_element(frecord: FileRecord) -> ET.Element:
145
160
  href.text = f"/{frecord.url}"
146
161
  propstat = ET.SubElement(file_el, f"{{{DAV_NS}}}propstat")
147
162
  prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
148
- ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = frecord.url.split("/")[-1]
163
+ ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = decode_uri_compnents(frecord.url.split("/")[-1])
149
164
  ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype")
150
165
  ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(frecord.file_size)
151
166
  ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(frecord.create_time)
152
167
  ET.SubElement(prop, f"{{{DAV_NS}}}getcontenttype").text = frecord.mime_type
153
- lock_el = await query_lock_el(frecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
168
+ lock_el = await query_lock_element(frecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
154
169
  if lock_el is not None:
155
170
  lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
156
171
  lock_discovery.append(lock_el)
172
+ ET.SubElement(propstat, f"{{{DAV_NS}}}status").text = "HTTP/1.1 200 OK"
157
173
  return file_el
158
174
 
159
175
  async def create_dir_xml_element(drecord: DirectoryRecord) -> ET.Element:
@@ -162,15 +178,16 @@ async def create_dir_xml_element(drecord: DirectoryRecord) -> ET.Element:
162
178
  href.text = f"/{drecord.url}"
163
179
  propstat = ET.SubElement(dir_el, f"{{{DAV_NS}}}propstat")
164
180
  prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
165
- ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = drecord.url.split("/")[-2]
181
+ ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = decode_uri_compnents(drecord.url.split("/")[-2])
166
182
  ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype").append(ET.Element(f"{{{DAV_NS}}}collection"))
167
183
  if drecord.size >= 0:
168
184
  ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(drecord.create_time)
169
185
  ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(drecord.size)
170
- lock_el = await query_lock_el(drecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
186
+ lock_el = await query_lock_element(drecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
171
187
  if lock_el is not None:
172
188
  lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
173
189
  lock_discovery.append(lock_el)
190
+ ET.SubElement(propstat, f"{{{DAV_NS}}}status").text = "HTTP/1.1 200 OK"
174
191
  return dir_el
175
192
 
176
193
  async def xml_request_body(request: Request) -> Optional[ET.Element]:
@@ -186,64 +203,59 @@ async def dav_options(request: Request, path: str):
186
203
  return Response(headers={
187
204
  "DAV": "1,2",
188
205
  "MS-Author-Via": "DAV",
189
- "Allow": "OPTIONS, GET, HEAD, POST, DELETE, TRACE, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK",
206
+ "Allow": "OPTIONS, GET, HEAD, POST, DELETE, TRACE, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK, MKCOL",
190
207
  "Content-Length": "0"
191
208
  })
192
209
 
193
- @router_dav.get("/{path:path}")
194
- @handle_exception
195
- async def dav_get(request: Request, path: str, user: UserRecord = Depends(get_current_user)):
196
- ptype, path, _ = await eval_path(path)
197
- if ptype is None: raise PathNotFoundError(path)
198
- # elif ptype == "dir": raise InvalidOptionsError("Directory should not be fetched")
199
- else: return await get_file_impl(request, user=user, path=path)
200
-
201
- @router_dav.head("/{path:path}")
202
- @handle_exception
203
- async def dav_head(request: Request, path: str, user: UserRecord = Depends(registered_user)):
204
- ptype, path, _ = await eval_path(path)
205
- # some clients may send HEAD request to check if the file exists
206
- if ptype is None: raise PathNotFoundError(path)
207
- elif ptype == "dir": return Response(status_code=200)
208
- else: return await get_file_impl(request, user=user, path=path, is_head=True)
209
-
210
- @router_dav.put("/{path:path}")
211
- @handle_exception
212
- async def dav_put(request: Request, path: str, user: UserRecord = Depends(registered_user)):
213
- _, path, _ = await eval_path(path)
214
- return await put_file_impl(request, user=user, path=path, conflict='overwrite')
215
-
216
- @router_dav.delete("/{path:path}")
217
- @handle_exception
218
- async def dav_delete(path: str, user: UserRecord = Depends(registered_user)):
219
- _, path, _ = await eval_path(path)
220
- return await delete_impl(user=user, path=path)
221
-
222
210
  @router_dav.api_route("/{path:path}", methods=["PROPFIND"])
223
211
  @handle_exception
224
- async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(registered_user)):
212
+ async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(registered_user), body: Optional[ET.Element] = Depends(xml_request_body)):
225
213
  if path.startswith("/"): path = path[1:]
226
214
  path = ensure_uri_compnents(path)
227
215
 
228
- depth = request.headers.get("Depth", "1")
216
+ if body and DEBUG_MODE:
217
+ print("Propfind-body:", ET.tostring(body, encoding="utf-8", method="xml"))
218
+
219
+ depth = request.headers.get("Depth", "0")
229
220
  # Generate XML response
230
221
  multistatus = ET.Element(f"{{{DAV_NS}}}multistatus")
231
222
  path_type, lfss_path, record = await eval_path(path)
232
- logger.info(f"PROPFIND {lfss_path} (depth: {depth})")
233
- return_status = 200
223
+ logger.info(f"PROPFIND {lfss_path} (depth: {depth}), type: {path_type}, record: {record}")
224
+
225
+ if lfss_path and await check_path_permission(lfss_path, user) < AccessLevel.READ:
226
+ raise PermissionDeniedError(lfss_path)
227
+
234
228
  if path_type == "dir" and depth == "0":
235
229
  # query the directory itself
236
- return_status = 200
237
230
  assert isinstance(record, DirectoryRecord)
238
231
  dir_el = await create_dir_xml_element(record)
239
232
  multistatus.append(dir_el)
240
233
 
234
+ elif path_type == "dir" and lfss_path == "":
235
+ # query root directory content
236
+ async def user_path_record(user_name: str, cur) -> DirectoryRecord:
237
+ try:
238
+ return await FileConn(cur).get_path_record(user_name + "/")
239
+ except PathNotFoundError:
240
+ return DirectoryRecord(user_name + "/", size=0, n_files=0, create_time="1970-01-01 00:00:00", update_time="1970-01-01 00:00:00", access_time="1970-01-01 00:00:00")
241
+
242
+ async with unique_cursor() as c:
243
+ uconn = UserConn(c)
244
+ if not user.is_admin:
245
+ for u in [user] + await uconn.list_peer_users(user.id, AccessLevel.READ):
246
+ dir_el = await create_dir_xml_element(await user_path_record(u.username, c))
247
+ multistatus.append(dir_el)
248
+ else:
249
+ async for u in uconn.all():
250
+ dir_el = await create_dir_xml_element(await user_path_record(u.username, c))
251
+ multistatus.append(dir_el)
252
+
241
253
  elif path_type == "dir":
242
- return_status = 207
254
+ # query directory content
243
255
  async with unique_cursor() as c:
244
256
  flist = await FileConn(c).list_path_files(lfss_path, flat = True if depth == "infinity" else False)
245
257
  for frecord in flist:
246
- if frecord.url.split("/")[-1] == MKDIR_PLACEHOLDER: continue
258
+ if frecord.url.endswith(f"/{MKDIR_PLACEHOLDER}"): continue
247
259
  file_el = await create_file_xml_element(frecord)
248
260
  multistatus.append(file_el)
249
261
 
@@ -254,6 +266,7 @@ async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(r
254
266
  multistatus.append(dir_el)
255
267
 
256
268
  elif path_type == "file":
269
+ # query file
257
270
  assert isinstance(record, FileRecord)
258
271
  file_el = await create_file_xml_element(record)
259
272
  multistatus.append(file_el)
@@ -262,7 +275,7 @@ async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(r
262
275
  raise PathNotFoundError(path)
263
276
 
264
277
  xml_response = ET.tostring(multistatus, encoding="utf-8", method="xml")
265
- return Response(content=xml_response, media_type="application/xml", status_code=return_status)
278
+ return Response(content=xml_response, media_type="application/xml", status_code=207)
266
279
 
267
280
  @router_dav.api_route("/{path:path}", methods=["MKCOL"])
268
281
  @handle_exception
@@ -289,7 +302,7 @@ async def dav_move(request: Request, path: str, user: UserRecord = Depends(regis
289
302
  ptype, lfss_path, _ = await eval_path(path)
290
303
  if ptype is None:
291
304
  raise PathNotFoundError(path)
292
- dptype, dlfss_path, ddav_path = await eval_path(destination)
305
+ dptype, dlfss_path, _ = await eval_path(destination)
293
306
  if dptype is not None:
294
307
  raise HTTPException(status_code=409, detail="Conflict")
295
308
 
@@ -339,13 +352,13 @@ async def dav_lock(request: Request, path: str, user: UserRecord = Depends(regis
339
352
  # lock_token = f"opaquelocktoken:{uuid.uuid4().hex}"
340
353
  lock_token = f"urn:uuid:{uuid.uuid4()}"
341
354
  logger.info(f"LOCK {path} (timeout: {timeout}), token: {lock_token}, depth: {lock_depth}")
342
- if DEBUG_MODE:
355
+ if DEBUG_MODE and body:
343
356
  print("Lock-body:", ET.tostring(body, encoding="utf-8", method="xml"))
344
357
  async with dav_lock.lock:
345
358
  await lock_path(user, path, lock_token, lock_depth, timeout=timeout)
346
359
  response_elem = ET.Element(f"{{{DAV_NS}}}prop")
347
360
  lockdiscovery = ET.SubElement(response_elem, f"{{{DAV_NS}}}lockdiscovery")
348
- activelock = await query_lock_el(path, top_el_name=f"{{{DAV_NS}}}activelock")
361
+ activelock = await query_lock_element(path, top_el_name=f"{{{DAV_NS}}}activelock")
349
362
  assert activelock is not None
350
363
  lockdiscovery.append(activelock)
351
364
  lock_response = ET.tostring(response_elem, encoding="utf-8", method="xml")
@@ -362,7 +375,7 @@ async def dav_unlock(request: Request, path: str, user: UserRecord = Depends(reg
362
375
  if lock_token.startswith("<") and lock_token.endswith(">"):
363
376
  lock_token = lock_token[1:-1]
364
377
  logger.info(f"UNLOCK {path}, token: {lock_token}")
365
- if DEBUG_MODE:
378
+ if DEBUG_MODE and body:
366
379
  print("Unlock-body:", ET.tostring(body, encoding="utf-8", method="xml"))
367
380
  _, path, _ = await eval_path(path)
368
381
  await unlock_path(user, path, lock_token)
lfss/svc/app_native.py CHANGED
@@ -13,7 +13,7 @@ from ..eng.datatype import (
13
13
  )
14
14
 
15
15
  from .app_base import *
16
- from .common_impl import get_file_impl, put_file_impl, post_file_impl, delete_impl, copy_impl
16
+ from .common_impl import get_impl, put_file_impl, post_file_impl, delete_impl, copy_impl
17
17
 
18
18
  @router_fs.get("/{path:path}")
19
19
  @handle_exception
@@ -23,7 +23,7 @@ async def get_file(
23
23
  download: bool = False, thumb: bool = False,
24
24
  user: UserRecord = Depends(get_current_user)
25
25
  ):
26
- return await get_file_impl(
26
+ return await get_impl(
27
27
  request = request,
28
28
  user = user, path = path, download = download, thumb = thumb
29
29
  )
@@ -38,9 +38,7 @@ async def head_file(
38
38
  ):
39
39
  if path.startswith("_api/"):
40
40
  raise HTTPException(status_code=405, detail="HEAD not supported for API")
41
- if path.endswith("/"):
42
- raise HTTPException(status_code=405, detail="HEAD not supported for directory")
43
- return await get_file_impl(
41
+ return await get_impl(
44
42
  request = request,
45
43
  user = user, path = path, download = download, thumb = thumb, is_head = True
46
44
  )
@@ -50,7 +48,7 @@ async def head_file(
50
48
  async def put_file(
51
49
  request: Request,
52
50
  path: str,
53
- conflict: Literal["overwrite", "skip", "abort"] = "abort",
51
+ conflict: Literal["overwrite", "skip", "abort"] = "overwrite",
54
52
  permission: int = 0,
55
53
  user: UserRecord = Depends(registered_user)
56
54
  ):
@@ -64,7 +62,7 @@ async def put_file(
64
62
  async def post_file(
65
63
  path: str,
66
64
  file: UploadFile,
67
- conflict: Literal["overwrite", "skip", "abort"] = "abort",
65
+ conflict: Literal["overwrite", "skip", "abort"] = "overwrite",
68
66
  permission: int = 0,
69
67
  user: UserRecord = Depends(registered_user)
70
68
  ):
lfss/svc/common_impl.py CHANGED
@@ -7,7 +7,7 @@ from ..eng.datatype import UserRecord, FileRecord, PathContents, AccessLevel, Fi
7
7
  from ..eng.database import FileConn, UserConn, delayed_log_access, check_file_read_permission, check_path_permission
8
8
  from ..eng.thumb import get_thumb
9
9
  from ..eng.utils import format_last_modified, ensure_uri_compnents
10
- from ..eng.config import CHUNK_SIZE
10
+ from ..eng.config import CHUNK_SIZE, DEBUG_MODE
11
11
 
12
12
  from .app_base import skip_request_log, db, logger
13
13
 
@@ -60,10 +60,15 @@ async def emit_file(
60
60
  else:
61
61
  arng_e = range_end
62
62
 
63
- if arng_s >= file_record.file_size or arng_e >= file_record.file_size:
64
- raise HTTPException(status_code=416, detail="Range not satisfiable")
65
- if arng_s > arng_e:
66
- raise HTTPException(status_code=416, detail="Invalid range")
63
+ if file_record.file_size > 0:
64
+ if arng_s >= file_record.file_size or arng_e >= file_record.file_size:
65
+ if DEBUG_MODE: print(f"[Invalid range] Actual range: {arng_s}-{arng_e} (size: {file_record.file_size})")
66
+ raise HTTPException(status_code=416, detail="Range not satisfiable")
67
+ if arng_s > arng_e:
68
+ raise HTTPException(status_code=416, detail="Invalid range")
69
+ else:
70
+ if not (arng_s == 0 and arng_e == -1):
71
+ raise HTTPException(status_code=416, detail="Invalid range (file size is 0)")
67
72
 
68
73
  headers = {
69
74
  "Content-Disposition": f"{disposition}; filename={fname}",
@@ -87,7 +92,7 @@ async def emit_file(
87
92
  status_code=206 if range_start != -1 or range_end != -1 else 200
88
93
  )
89
94
 
90
- async def get_file_impl(
95
+ async def get_impl(
91
96
  request: Request,
92
97
  user: UserRecord,
93
98
  path: str,
@@ -96,30 +101,12 @@ async def get_file_impl(
96
101
  is_head = False,
97
102
  ):
98
103
  path = ensure_uri_compnents(path)
104
+ if path.startswith("/"): path = path[1:]
99
105
 
100
106
  # handle directory query
101
107
  if path == "": path = "/"
102
108
  if path.endswith("/"):
103
- # return file under the path as json
104
- async with unique_cursor() as cur:
105
- fconn = FileConn(cur)
106
- if user.id == 0:
107
- raise HTTPException(status_code=401, detail="Permission denied, credential required")
108
- if thumb:
109
- return await emit_thumbnail(path, download, create_time=None)
110
-
111
- if path == "/":
112
- peer_users = await UserConn(cur).list_peer_users(user.id, AccessLevel.READ)
113
- return PathContents(
114
- dirs = await fconn.list_root_dirs(user.username, *[x.username for x in peer_users], skim=True) \
115
- if not user.is_admin else await fconn.list_root_dirs(skim=True),
116
- files = []
117
- )
118
-
119
- if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
120
- raise HTTPException(status_code=403, detail="Permission denied")
121
-
122
- return await fconn.list_path(path)
109
+ return await _get_dir_impl(user=user, path=path, download=download, thumb=thumb, is_head=is_head)
123
110
 
124
111
  # handle file query
125
112
  async with unique_cursor() as cur:
@@ -147,6 +134,9 @@ async def get_file_impl(
147
134
  else:
148
135
  range_start, range_end = -1, -1
149
136
 
137
+ if DEBUG_MODE:
138
+ print(f"Get range: {range_start}-{range_end}")
139
+
150
140
  if thumb:
151
141
  if (range_start != -1 or range_end != -1): logger.warning("Range request for thumbnail")
152
142
  return await emit_thumbnail(path, download, create_time=file_record.create_time, is_head=is_head)
@@ -156,11 +146,55 @@ async def get_file_impl(
156
146
  else:
157
147
  return await emit_file(file_record, None, "inline", is_head = is_head, range_start=range_start, range_end=range_end)
158
148
 
149
+ async def _get_dir_impl(
150
+ user: UserRecord,
151
+ path: str,
152
+ download: bool = False,
153
+ thumb: bool = False,
154
+ is_head = False,
155
+ ):
156
+ """ handle directory query, return file under the path as json """
157
+ assert path.endswith("/")
158
+ async with unique_cursor() as cur:
159
+ fconn = FileConn(cur)
160
+ if user.id == 0:
161
+ raise HTTPException(status_code=401, detail="Permission denied, credential required")
162
+ if thumb:
163
+ return await emit_thumbnail(path, download, create_time=None)
164
+
165
+ if path == "/":
166
+ if is_head: return Response(status_code=200)
167
+ peer_users = await UserConn(cur).list_peer_users(user.id, AccessLevel.READ)
168
+ return PathContents(
169
+ dirs = await fconn.list_root_dirs(user.username, *[x.username for x in peer_users], skim=True) \
170
+ if not user.is_admin else await fconn.list_root_dirs(skim=True),
171
+ files = []
172
+ )
173
+
174
+ if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
175
+ raise HTTPException(status_code=403, detail="Permission denied")
176
+
177
+ path_sp = path.split("/")
178
+ if is_head:
179
+ if len(path_sp) == 2:
180
+ assert path_sp[1] == ""
181
+ if await UserConn(cur).get_user(path_sp[0]):
182
+ return Response(status_code=200)
183
+ else:
184
+ raise HTTPException(status_code=404, detail="User not found")
185
+ else:
186
+ if await FileConn(cur).count_path_files(path, flat=True) > 0:
187
+ return Response(status_code=200)
188
+ else:
189
+ raise HTTPException(status_code=404, detail="Path not found")
190
+
191
+ return await fconn.list_path(path)
192
+
159
193
  async def put_file_impl(
160
194
  request: Request,
161
195
  user: UserRecord,
162
196
  path: str,
163
- conflict: Literal["overwrite", "skip", "abort"] = "abort",
197
+ conflict: Literal["overwrite", "skip", "abort"] = "overwrite",
164
198
  permission: int = 0,
165
199
  ):
166
200
  path = ensure_uri_compnents(path)
@@ -187,7 +221,9 @@ async def put_file_impl(
187
221
  exists_flag = True
188
222
  if await check_path_permission(path, user) < AccessLevel.WRITE:
189
223
  raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
190
- await db.delete_file(path)
224
+ old_record = await db.delete_file(path)
225
+ if old_record and permission == FileReadPermission.UNSET.value:
226
+ permission = old_record.permission.value # inherit permission
191
227
 
192
228
  # check content-type
193
229
  content_type = request.headers.get("Content-Type", "application/octet-stream")
@@ -213,7 +249,7 @@ async def post_file_impl(
213
249
  path: str,
214
250
  user: UserRecord,
215
251
  file: UploadFile,
216
- conflict: Literal["overwrite", "skip", "abort"] = "abort",
252
+ conflict: Literal["overwrite", "skip", "abort"] = "overwrite",
217
253
  permission: int = 0,
218
254
  ):
219
255
  path = ensure_uri_compnents(path)
@@ -240,7 +276,9 @@ async def post_file_impl(
240
276
  exists_flag = True
241
277
  if await check_path_permission(path, user) < AccessLevel.WRITE:
242
278
  raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
243
- await db.delete_file(path)
279
+ old_record = await db.delete_file(path)
280
+ if old_record and permission == FileReadPermission.UNSET.value:
281
+ permission = old_record.permission.value # inherit permission
244
282
 
245
283
  async def blob_reader():
246
284
  nonlocal file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li_mengxun
@@ -1,8 +1,9 @@
1
1
  Readme.md,sha256=JVe9T6N1Rz4hTiiCVoDYe2VB0dAi60VcBgb2twQdfZc,1834
2
+ docs/Changelog.md,sha256=3mRHcda4UK8c105XtBfbeTWij0S4xNc-U8JTTPUqCJk,769
2
3
  docs/Enviroment_variables.md,sha256=LUZF1o70emp-5UPsvXPjcxapP940OqEZzSyyUUT9bEQ,569
3
4
  docs/Known_issues.md,sha256=ZqETcWP8lzTOel9b2mxEgCnADFF8IxOrEtiVO1NoMAk,251
4
5
  docs/Permission.md,sha256=mvK8gVBBgoIFJqikcaReU_bUo-mTq_ECqJaDDJoQF7Q,3126
5
- docs/Webdav.md,sha256=9Q41ROEJodVVAnlo1Tf0jqsyrbuHhv_ElSsXbIPXYIg,1547
6
+ docs/Webdav.md,sha256=-Ja-BTWSY1BEMAyZycvEMNnkNTPZ49gSPzmf3Lbib70,1547
6
7
  frontend/api.js,sha256=GlQsNoZFEcy7QUUsLbXv7aP-KxRnIxM37FQHTaakGiQ,19387
7
8
  frontend/index.html,sha256=-k0bJ5FRqdl_H-O441D_H9E-iejgRCaL_z5UeYaS2qc,3384
8
9
  frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
@@ -29,22 +30,22 @@ lfss/cli/vacuum.py,sha256=GOG72d3NYe9bYCNc3y8JecEmM-DrKlGq3JQcisv_xBg,3702
29
30
  lfss/eng/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
31
  lfss/eng/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
31
32
  lfss/eng/config.py,sha256=DmnUYMeLOL-45OstysyMpSBPmLofgzvcSrsWjHvssYs,915
32
- lfss/eng/connection_pool.py,sha256=-tePasJxiZZ73ymgWf_kFnaKouc4Rrr4K6EXwjb7Mm4,6141
33
- lfss/eng/database.py,sha256=cfMq7Hgj8cHFtynDzpRiqb0XYNb6OKWMYc8PcWl8eVw,47285
33
+ lfss/eng/connection_pool.py,sha256=4xOF1kXXGqCWeLX5ZVFALKjdY8N1VVAVSSTRfCzbj94,6141
34
+ lfss/eng/database.py,sha256=2i8gbh1odOA09tS5VU9cUZy3poZUdCx3XX7UX7umtxw,47188
34
35
  lfss/eng/datatype.py,sha256=27UB7-l9SICy5lAvKjdzpTL_GohZjzstQcr9PtAq7nM,2709
35
- lfss/eng/error.py,sha256=sDbXo2R3APJAV0KtoYGCHx2qVZso7svtDzq-WjnzhAw,595
36
+ lfss/eng/error.py,sha256=dAlQHXOnQcSkA2vTugJFSxcyDqoFlPucBoFpTZ7GI6w,654
36
37
  lfss/eng/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
37
- lfss/eng/thumb.py,sha256=YO1yTI8WzW7pBpQN9x5PtPayxhftb32IJl1zPSS9mks,3243
38
- lfss/eng/utils.py,sha256=zZ7r9BsNV8XJJVNOxfIqRCO1bxNzh7bc9vEJiCkgbKI,6208
38
+ lfss/eng/thumb.py,sha256=x9jIHHU1tskmp-TavPPcxGpbmEjCp9gbH6ZlsEfqUxY,3383
39
+ lfss/eng/utils.py,sha256=CYEQvPiM28k53hCJBE7N6O6a1xC_wvnP3KZx4DCnD0k,6723
39
40
  lfss/sql/init.sql,sha256=8LjHx0TBCkBD62xFfssSeHDqKYVQQJkZAg4rSm046f4,1496
40
41
  lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
41
42
  lfss/svc/app.py,sha256=ftWCpepBx-gTSG7i-TB-IdinPPstAYYQjCgnTfeMZeI,219
42
- lfss/svc/app_base.py,sha256=nc02DP4iMKP41fRl8M-iAhbHwyb4QJJTKKSJwtdCox4,6617
43
- lfss/svc/app_dav.py,sha256=nPMdPsYNcgxqHOt5bDaaA0Wy8AdRDJajEda_-KxOoHA,17466
44
- lfss/svc/app_native.py,sha256=xwMCOWp4ne3rmtiiYhfxETi__V-zPEfHw-c4iWNtXWc,9471
45
- lfss/svc/common_impl.py,sha256=_biK0F_AAw4PnMNWR0WuHJSRyIp1iTSOOIPBauZCJ9M,12143
43
+ lfss/svc/app_base.py,sha256=BU_DndHW4sYiWUQcTis8iGljmUy8FHfZrzCkE0d1z-Y,6717
44
+ lfss/svc/app_dav.py,sha256=D0KSgjtTktPjIhyIKG5eRmBdh5X8HYFYH151E6gzlbc,18245
45
+ lfss/svc/app_native.py,sha256=6yBRJB8_p4RZgDVheDTv1ClBGc3etrQm94j1NiR4FUQ,9349
46
+ lfss/svc/common_impl.py,sha256=0fjbqHWgqDhLfBEu6aC0Z5qgNt67C7z0Qroj7aV3Iq4,13830
46
47
  lfss/svc/request_log.py,sha256=v8yXEIzPjaksu76Oh5vgdbUEUrw8Kt4etLAXBWSGie8,3207
47
- lfss-0.9.2.dist-info/METADATA,sha256=0Q5klZ2iwBF1ZUQ5iximW02mMmoAM5ib08s0IsdyuLE,2594
48
- lfss-0.9.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
49
- lfss-0.9.2.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
50
- lfss-0.9.2.dist-info/RECORD,,
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,,
File without changes