lfss 0.8.4__py3-none-any.whl → 0.9.1__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.
@@ -1,5 +1,6 @@
1
1
 
2
- from typing import Optional, Literal, AsyncIterable
2
+ from typing import Optional, Literal, AsyncIterable, overload
3
+ from contextlib import asynccontextmanager
3
4
  from abc import ABC
4
5
 
5
6
  import urllib.parse
@@ -12,12 +13,13 @@ import mimetypes, mimesniff
12
13
 
13
14
  from .connection_pool import execute_sql, unique_cursor, transaction
14
15
  from .datatype import (
15
- UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents,
16
+ UserRecord, AccessLevel,
17
+ FileReadPermission, FileRecord, DirectoryRecord, PathContents,
16
18
  FileSortKey, DirSortKey, isValidFileSortKey, isValidDirSortKey
17
19
  )
18
20
  from .config import LARGE_BLOB_DIR, CHUNK_SIZE, LARGE_FILE_BYTES, MAX_MEM_FILE_BYTES
19
21
  from .log import get_logger
20
- from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async
22
+ from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async, copy_file
21
23
  from .error import *
22
24
 
23
25
  class DBObjectBase(ABC):
@@ -57,11 +59,16 @@ class UserConn(DBObjectBase):
57
59
  if res is None: return None
58
60
  return self.parse_record(res)
59
61
 
60
- async def get_user_by_id(self, user_id: int) -> Optional[UserRecord]:
62
+ @overload
63
+ async def get_user_by_id(self, user_id: int, throw: Literal[True]) -> UserRecord: ...
64
+ @overload
65
+ async def get_user_by_id(self, user_id: int, throw: Literal[False] = False) -> Optional[UserRecord]: ...
66
+ async def get_user_by_id(self, user_id: int, throw = False) -> Optional[UserRecord]:
61
67
  await self.cur.execute("SELECT * FROM user WHERE id = ?", (user_id, ))
62
68
  res = await self.cur.fetchone()
63
-
64
- if res is None: return None
69
+ if res is None:
70
+ if throw: raise ValueError(f"User {user_id} not found")
71
+ return None
65
72
  return self.parse_record(res)
66
73
 
67
74
  async def get_user_by_credential(self, credential: str) -> Optional[UserRecord]:
@@ -122,8 +129,58 @@ class UserConn(DBObjectBase):
122
129
  await self.cur.execute("UPDATE user SET last_active = CURRENT_TIMESTAMP WHERE username = ?", (username, ))
123
130
 
124
131
  async def delete_user(self, username: str):
132
+ await self.cur.execute("DELETE FROM upeer WHERE src_user_id = (SELECT id FROM user WHERE username = ?) OR dst_user_id = (SELECT id FROM user WHERE username = ?)", (username, username))
125
133
  await self.cur.execute("DELETE FROM user WHERE username = ?", (username, ))
126
134
  self.logger.info(f"Delete user {username}")
135
+
136
+ async def set_peer_level(self, src_user: int | str, dst_user: int | str, level: AccessLevel):
137
+ """ src_user can do [AccessLevel] to dst_user """
138
+ assert int(level) >= AccessLevel.NONE, f"Cannot set alias level to {level}"
139
+ match (src_user, dst_user):
140
+ case (int(), int()):
141
+ await self.cur.execute("INSERT OR REPLACE INTO upeer (src_user_id, dst_user_id, access_level) VALUES (?, ?, ?)", (src_user, dst_user, int(level)))
142
+ case (str(), str()):
143
+ await self.cur.execute("INSERT OR REPLACE INTO upeer (src_user_id, dst_user_id, access_level) VALUES ((SELECT id FROM user WHERE username = ?), (SELECT id FROM user WHERE username = ?), ?)", (src_user, dst_user, int(level)))
144
+ case (str(), int()):
145
+ await self.cur.execute("INSERT OR REPLACE INTO upeer (src_user_id, dst_user_id, access_level) VALUES ((SELECT id FROM user WHERE username = ?), ?, ?)", (src_user, dst_user, int(level)))
146
+ case (int(), str()):
147
+ await self.cur.execute("INSERT OR REPLACE INTO upeer (src_user_id, dst_user_id, access_level) VALUES (?, (SELECT id FROM user WHERE username = ?), ?)", (src_user, dst_user, int(level)))
148
+ case (_, _):
149
+ raise ValueError("Invalid arguments")
150
+
151
+ async def query_peer_level(self, src_user_id: int, dst_user_id: int) -> AccessLevel:
152
+ """ src_user can do [AliasLevel] to dst_user """
153
+ if src_user_id == dst_user_id:
154
+ return AccessLevel.ALL
155
+ await self.cur.execute("SELECT access_level FROM upeer WHERE src_user_id = ? AND dst_user_id = ?", (src_user_id, dst_user_id))
156
+ res = await self.cur.fetchone()
157
+ if res is None:
158
+ return AccessLevel.NONE
159
+ return AccessLevel(res[0])
160
+
161
+ async def list_peer_users(self, src_user: int | str, level: AccessLevel) -> list[UserRecord]:
162
+ """
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
165
+ """
166
+ assert int(level) > AccessLevel.NONE, f"Invalid level, {level}"
167
+ match src_user:
168
+ case int():
169
+ await self.cur.execute("""
170
+ SELECT * FROM user WHERE id IN (
171
+ SELECT dst_user_id FROM upeer WHERE src_user_id = ? AND access_level >= ?
172
+ )
173
+ """, (src_user, int(level)))
174
+ case str():
175
+ await self.cur.execute("""
176
+ SELECT * FROM user WHERE id IN (
177
+ SELECT dst_user_id FROM upeer WHERE src_user_id = (SELECT id FROM user WHERE username = ?) AND access_level >= ?
178
+ )
179
+ """, (src_user, int(level)))
180
+ case _:
181
+ raise ValueError("Invalid arguments")
182
+ res = await self.cur.fetchall()
183
+ return [self.parse_record(r) for r in res]
127
184
 
128
185
  class FileConn(DBObjectBase):
129
186
 
@@ -135,10 +192,15 @@ class FileConn(DBObjectBase):
135
192
  def parse_record(record) -> FileRecord:
136
193
  return FileRecord(*record)
137
194
 
138
- async def get_file_record(self, url: str) -> Optional[FileRecord]:
195
+ @overload
196
+ async def get_file_record(self, url: str, throw: Literal[True]) -> FileRecord: ...
197
+ @overload
198
+ async def get_file_record(self, url: str, throw: Literal[False] = False) -> Optional[FileRecord]: ...
199
+ async def get_file_record(self, url: str, throw = False):
139
200
  cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url = ?", (url, ))
140
201
  res = await cursor.fetchone()
141
202
  if res is None:
203
+ if throw: raise FileNotFoundError(f"File {url} not found")
142
204
  return None
143
205
  return self.parse_record(res)
144
206
 
@@ -343,6 +405,57 @@ class FileConn(DBObjectBase):
343
405
  )
344
406
  await self._user_size_inc(owner_id, file_size)
345
407
  self.logger.info(f"File {url} created")
408
+
409
+ # not tested
410
+ async def copy_file(self, old_url: str, new_url: str, user_id: Optional[int] = None):
411
+ old = await self.get_file_record(old_url)
412
+ if old is None:
413
+ raise FileNotFoundError(f"File {old_url} not found")
414
+ new_exists = await self.get_file_record(new_url)
415
+ if new_exists is not None:
416
+ raise FileExistsError(f"File {new_url} already exists")
417
+ new_fid = str(uuid.uuid4())
418
+ user_id = old.owner_id if user_id is None else user_id
419
+ await self.cur.execute(
420
+ "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external, mime_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
421
+ (new_url, user_id, new_fid, old.file_size, old.permission, old.external, old.mime_type)
422
+ )
423
+ if not old.external:
424
+ await self.set_file_blob(new_fid, await self.get_file_blob(old.file_id))
425
+ else:
426
+ await copy_file(LARGE_BLOB_DIR / old.file_id, LARGE_BLOB_DIR / new_fid)
427
+ await self._user_size_inc(user_id, old.file_size)
428
+ self.logger.info(f"Copied file {old_url} to {new_url}")
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):
432
+ assert old_url.endswith('/'), "Old path must end with /"
433
+ assert new_url.endswith('/'), "New path must end with /"
434
+ if user_id is None:
435
+ cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (old_url + '%', ))
436
+ res = await cursor.fetchall()
437
+ else:
438
+ cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ? AND owner_id = ?", (old_url + '%', user_id))
439
+ res = await cursor.fetchall()
440
+ for r in res:
441
+ old_record = FileRecord(*r)
442
+ 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
448
+ new_fid = str(uuid.uuid4())
449
+ user_id = old_record.owner_id if user_id is None else user_id
450
+ await self.cur.execute(
451
+ "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external, mime_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
452
+ (new_r, user_id, new_fid, old_record.file_size, old_record.permission, old_record.external, old_record.mime_type)
453
+ )
454
+ if not old_record.external:
455
+ await self.set_file_blob(new_fid, await self.get_file_blob(old_record.file_id))
456
+ else:
457
+ await copy_file(LARGE_BLOB_DIR / old_record.file_id, LARGE_BLOB_DIR / new_fid)
458
+ await self._user_size_inc(user_id, old_record.file_size)
346
459
 
347
460
  async def move_file(self, old_url: str, new_url: str):
348
461
  old = await self.get_file_record(old_url)
@@ -552,7 +665,7 @@ class Database:
552
665
  if r is None:
553
666
  raise PathNotFoundError(f"File {url} not found")
554
667
  if op_user is not None:
555
- if r.owner_id != op_user.id and not op_user.is_admin:
668
+ if await check_path_permission(url, op_user) < AccessLevel.WRITE:
556
669
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot update file {url}")
557
670
  await fconn.update_file_record(url, permission=permission)
558
671
 
@@ -571,65 +684,78 @@ class Database:
571
684
  async with unique_cursor() as cur:
572
685
  user = await get_user(cur, u)
573
686
  assert user is not None, f"User {u} not found"
687
+
688
+ if await check_path_permission(url, user, cursor=cur) < AccessLevel.WRITE:
689
+ raise PermissionDeniedError(f"Permission denied: {user.username} cannot write to {url}")
574
690
 
575
691
  fconn_r = FileConn(cur)
576
692
  user_size_used = await fconn_r.user_size(user.id)
577
693
 
578
694
  f_id = uuid.uuid4().hex
579
- async with aiofiles.tempfile.SpooledTemporaryFile(max_size=MAX_MEM_FILE_BYTES) as f:
580
- async for chunk in blob_stream:
581
- await f.write(chunk)
582
- file_size = await f.tell()
583
- if user_size_used + file_size > user.max_storage:
584
- raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
585
-
586
- # check mime type
587
- if mime_type is None:
588
- mime_type, _ = mimetypes.guess_type(url)
589
- if mime_type is None:
590
- await f.seek(0)
591
- mime_type = mimesniff.what(await f.read(1024))
592
- if mime_type is None:
593
- mime_type = 'application/octet-stream'
695
+
696
+ async with aiofiles.tempfile.SpooledTemporaryFile(max_size=MAX_MEM_FILE_BYTES) as f:
697
+ async for chunk in blob_stream:
698
+ await f.write(chunk)
699
+ file_size = await f.tell()
700
+ if user_size_used + file_size > user.max_storage:
701
+ raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
702
+
703
+ # check mime type
704
+ if mime_type is None:
705
+ mime_type, _ = mimetypes.guess_type(url)
706
+ if mime_type is None:
594
707
  await f.seek(0)
595
-
596
- if file_size < LARGE_FILE_BYTES:
597
- blob = await f.read()
598
- async with transaction() as w_cur:
599
- fconn_w = FileConn(w_cur)
600
- await fconn_w.set_file_blob(f_id, blob)
601
- await fconn_w.set_file_record(
602
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
603
- permission=permission, external=False, mime_type=mime_type)
604
-
605
- else:
606
- async def blob_stream_tempfile():
607
- nonlocal f
608
- while True:
609
- chunk = await f.read(CHUNK_SIZE)
610
- if not chunk: break
611
- yield chunk
612
- await FileConn.set_file_blob_external(f_id, blob_stream_tempfile())
613
- async with transaction() as w_cur:
614
- await FileConn(w_cur).set_file_record(
615
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
616
- permission=permission, external=True, mime_type=mime_type)
708
+ mime_type = mimesniff.what(await f.read(1024))
709
+ if mime_type is None:
710
+ mime_type = 'application/octet-stream'
711
+ await f.seek(0)
712
+
713
+ if file_size < LARGE_FILE_BYTES:
714
+ blob = await f.read()
715
+ async with transaction() as w_cur:
716
+ fconn_w = FileConn(w_cur)
717
+ await fconn_w.set_file_blob(f_id, blob)
718
+ await fconn_w.set_file_record(
719
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
720
+ permission=permission, external=False, mime_type=mime_type)
721
+
722
+ else:
723
+ async def blob_stream_tempfile():
724
+ nonlocal f
725
+ while True:
726
+ chunk = await f.read(CHUNK_SIZE)
727
+ if not chunk: break
728
+ yield chunk
729
+ await FileConn.set_file_blob_external(f_id, blob_stream_tempfile())
730
+ async with transaction() as w_cur:
731
+ await FileConn(w_cur).set_file_record(
732
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
733
+ permission=permission, external=True, mime_type=mime_type)
617
734
  return file_size
618
735
 
619
736
  async def read_file(self, url: str, start_byte = -1, end_byte = -1) -> AsyncIterable[bytes]:
620
- # end byte is exclusive: [start_byte, end_byte)
737
+ """
738
+ Read a file from the database.
739
+ end byte is exclusive: [start_byte, end_byte)
740
+ """
741
+ # The implementation is tricky, should not keep the cursor open for too long
621
742
  validate_url(url)
622
743
  async with unique_cursor() as cur:
623
744
  fconn = FileConn(cur)
624
745
  r = await fconn.get_file_record(url)
625
746
  if r is None:
626
747
  raise FileNotFoundError(f"File {url} not found")
748
+
627
749
  if r.external:
628
- ret = fconn.get_file_blob_external(r.file_id, start_byte=start_byte, end_byte=end_byte)
750
+ _blob_stream = fconn.get_file_blob_external(r.file_id, start_byte=start_byte, end_byte=end_byte)
751
+ async def blob_stream():
752
+ async for chunk in _blob_stream:
753
+ yield chunk
629
754
  else:
755
+ blob = await fconn.get_file_blob(r.file_id, start_byte=start_byte, end_byte=end_byte)
630
756
  async def blob_stream():
631
- yield await fconn.get_file_blob(r.file_id, start_byte=start_byte, end_byte=end_byte)
632
- ret = blob_stream()
757
+ yield blob
758
+ ret = blob_stream()
633
759
  return ret
634
760
 
635
761
  async def delete_file(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
@@ -641,7 +767,8 @@ class Database:
641
767
  if r is None:
642
768
  return None
643
769
  if op_user is not None:
644
- if r.owner_id != op_user.id and not op_user.is_admin:
770
+ if r.owner_id != op_user.id and \
771
+ await check_path_permission(r.url, op_user, cursor=cur) < AccessLevel.WRITE:
645
772
  # will rollback
646
773
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot delete file {url}")
647
774
  f_id = r.file_id
@@ -661,7 +788,7 @@ class Database:
661
788
  if r is None:
662
789
  raise FileNotFoundError(f"File {old_url} not found")
663
790
  if op_user is not None:
664
- if r.owner_id != op_user.id:
791
+ if await check_path_permission(old_url, op_user, cursor=cur) < AccessLevel.WRITE:
665
792
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
666
793
  await fconn.move_file(old_url, new_url)
667
794
 
@@ -669,6 +796,23 @@ class Database:
669
796
  if not new_mime is None:
670
797
  await fconn.update_file_record(new_url, mime_type=new_mime)
671
798
 
799
+ # not tested
800
+ async def copy_file(self, old_url: str, new_url: str, op_user: Optional[UserRecord] = None):
801
+ validate_url(old_url)
802
+ validate_url(new_url)
803
+
804
+ async with transaction() as cur:
805
+ fconn = FileConn(cur)
806
+ r = await fconn.get_file_record(old_url)
807
+ if r is None:
808
+ raise FileNotFoundError(f"File {old_url} not found")
809
+ if op_user is not None:
810
+ if await check_path_permission(old_url, op_user, cursor=cur) < AccessLevel.READ:
811
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot copy file {old_url}")
812
+ if await check_path_permission(new_url, op_user, cursor=cur) < AccessLevel.WRITE:
813
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot copy file to {new_url}")
814
+ await fconn.copy_file(old_url, new_url, user_id=op_user.id if op_user is not None else None)
815
+
672
816
  async def move_path(self, old_url: str, new_url: str, op_user: UserRecord):
673
817
  validate_url(old_url, is_file=False)
674
818
  validate_url(new_url, is_file=False)
@@ -681,22 +825,40 @@ class Database:
681
825
  assert old_url.endswith('/'), "Old path must end with /"
682
826
  assert new_url.endswith('/'), "New path must end with /"
683
827
 
684
- async with transaction() as cur:
685
- first_component = new_url.split('/')[0]
686
- if not (first_component == op_user.username or op_user.is_admin):
687
- raise PermissionDeniedError(f"Permission denied: path must start with {op_user.username}")
688
- elif op_user.is_admin:
689
- uconn = UserConn(cur)
690
- _is_user = await uconn.get_user(first_component)
691
- if not _is_user:
692
- raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
693
-
694
- # check if old path is under user's directory (non-admin)
695
- if not old_url.startswith(op_user.username + '/') and not op_user.is_admin:
696
- raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move path {old_url}")
828
+ async with unique_cursor() as cur:
829
+ if not (
830
+ await check_path_permission(old_url, op_user, cursor=cur) >= AccessLevel.WRITE and
831
+ await check_path_permission(new_url, op_user, cursor=cur) >= AccessLevel.WRITE
832
+ ):
833
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move path {old_url} to {new_url}")
697
834
 
835
+ async with transaction() as cur:
698
836
  fconn = FileConn(cur)
699
837
  await fconn.move_path(old_url, new_url, 'overwrite', op_user.id)
838
+
839
+ # not tested
840
+ async def copy_path(self, old_url: str, new_url: str, op_user: UserRecord):
841
+ validate_url(old_url, is_file=False)
842
+ validate_url(new_url, is_file=False)
843
+
844
+ if new_url.startswith('/'):
845
+ new_url = new_url[1:]
846
+ if old_url.startswith('/'):
847
+ old_url = old_url[1:]
848
+ assert old_url != new_url, "Old and new path must be different"
849
+ assert old_url.endswith('/'), "Old path must end with /"
850
+ assert new_url.endswith('/'), "New path must end with /"
851
+
852
+ async with unique_cursor() as cur:
853
+ if not (
854
+ await check_path_permission(old_url, op_user, cursor=cur) >= AccessLevel.READ and
855
+ await check_path_permission(new_url, op_user, cursor=cur) >= AccessLevel.WRITE
856
+ ):
857
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot copy path {old_url} to {new_url}")
858
+
859
+ async with transaction() as cur:
860
+ fconn = FileConn(cur)
861
+ await fconn.copy_path(old_url, new_url, 'overwrite', op_user.id)
700
862
 
701
863
  async def __batch_delete_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
702
864
  # https://github.com/langchain-ai/langchain/issues/10321
@@ -718,7 +880,7 @@ class Database:
718
880
 
719
881
  async def delete_path(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
720
882
  validate_url(url, is_file=False)
721
- from_owner_id = op_user.id if op_user is not None and not op_user.is_admin else None
883
+ from_owner_id = op_user.id if op_user is not None and not (op_user.is_admin or await check_path_permission(url, op_user) >= AccessLevel.WRITE) else None
722
884
 
723
885
  async with transaction() as cur:
724
886
  fconn = FileConn(cur)
@@ -786,7 +948,15 @@ class Database:
786
948
  buffer.seek(0)
787
949
  return buffer
788
950
 
789
- def check_user_permission(user: UserRecord, owner: UserRecord, file: FileRecord) -> tuple[bool, str]:
951
+ def check_file_read_permission(user: UserRecord, owner: UserRecord, file: FileRecord) -> tuple[bool, str]:
952
+ """
953
+ This does not consider alias level permission,
954
+ use check_path_permission for alias level permission check first:
955
+ ```
956
+ if await check_path_permission(path, user) < AccessLevel.READ:
957
+ read_allowed, reason = check_file_read_permission(user, owner, file)
958
+ ```
959
+ """
790
960
  if user.is_admin:
791
961
  return True, ""
792
962
 
@@ -812,4 +982,47 @@ def check_user_permission(user: UserRecord, owner: UserRecord, file: FileRecord)
812
982
  else:
813
983
  assert owner.permission == FileReadPermission.PUBLIC or owner.permission == FileReadPermission.UNSET
814
984
 
815
- return True, ""
985
+ return True, ""
986
+
987
+ async def check_path_permission(path: str, user: UserRecord, cursor: Optional[aiosqlite.Cursor] = None) -> AccessLevel:
988
+ """
989
+ Check if the user has access to the path.
990
+ If the user is admin, the user will have all access.
991
+ If the path is a file, the user will have all access if the user is the owner.
992
+ Otherwise, the user will have alias level access w.r.t. the path user.
993
+ """
994
+ if user.id == 0:
995
+ return AccessLevel.GUEST
996
+
997
+ @asynccontextmanager
998
+ async def this_cur():
999
+ if cursor is None:
1000
+ async with unique_cursor() as _cur:
1001
+ yield _cur
1002
+ else:
1003
+ yield cursor
1004
+
1005
+ # check if path user exists
1006
+ path_username = path.split('/')[0]
1007
+ async with this_cur() as cur:
1008
+ uconn = UserConn(cur)
1009
+ path_user = await uconn.get_user(path_username)
1010
+ if path_user is None:
1011
+ raise PathNotFoundError(f"Invalid path: {path_username} is not a valid username")
1012
+
1013
+ # check if user is admin
1014
+ if user.is_admin or user.username == path_username:
1015
+ return AccessLevel.ALL
1016
+
1017
+ # if the path is a file, check if the user is the owner
1018
+ if not path.endswith('/'):
1019
+ async with this_cur() as cur:
1020
+ fconn = FileConn(cur)
1021
+ file = await fconn.get_file_record(path)
1022
+ if file and file.owner_id == user.id:
1023
+ return AccessLevel.ALL
1024
+
1025
+ # check alias level
1026
+ async with this_cur() as cur:
1027
+ uconn = UserConn(cur)
1028
+ return await uconn.query_peer_level(user.id, path_user.id)
@@ -8,6 +8,13 @@ class FileReadPermission(IntEnum):
8
8
  PROTECTED = 2 # accessible by any user
9
9
  PRIVATE = 3 # accessible by owner only (including admin)
10
10
 
11
+ class AccessLevel(IntEnum):
12
+ GUEST = -1 # guest, no permission
13
+ NONE = 0 # no permission
14
+ READ = 1 # read permission
15
+ WRITE = 2 # write/delete permission
16
+ ALL = 10 # all permission, currently same as WRITE
17
+
11
18
  @dataclasses.dataclass
12
19
  class UserRecord:
13
20
  id: int
@@ -1,6 +1,13 @@
1
+ import sqlite3
1
2
 
2
3
  class LFSSExceptionBase(Exception):...
3
4
 
5
+ class FileLockedError(LFSSExceptionBase):...
6
+
7
+ class InvalidOptionsError(LFSSExceptionBase, ValueError):...
8
+
9
+ class DatabaseLockedError(LFSSExceptionBase, sqlite3.DatabaseError):...
10
+
4
11
  class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
5
12
 
6
13
  class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
@@ -1,6 +1,6 @@
1
- from lfss.src.config import THUMB_DB, THUMB_SIZE
2
- from lfss.src.database import FileConn
3
- from lfss.src.connection_pool import unique_cursor
1
+ from lfss.eng.config import THUMB_DB, THUMB_SIZE
2
+ from lfss.eng.database import FileConn
3
+ from lfss.eng.connection_pool import unique_cursor
4
4
  from typing import Optional
5
5
  from PIL import Image
6
6
  from io import BytesIO
@@ -69,12 +69,13 @@ async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
69
69
  async with unique_cursor() as main_c:
70
70
  fconn = FileConn(main_c)
71
71
  r = await fconn.get_file_record(path)
72
- if r is None:
73
- async with cache_cursor() as cur:
74
- await _delete_cache_thumb(cur, path)
75
- raise FileNotFoundError(f'File not found: {path}')
76
- if not r.mime_type.startswith('image/'):
77
- return None
72
+
73
+ if r is None:
74
+ async with cache_cursor() as cur:
75
+ await _delete_cache_thumb(cur, path)
76
+ raise FileNotFoundError(f'File not found: {path}')
77
+ if not r.mime_type.startswith('image/'):
78
+ return None
78
79
 
79
80
  async with cache_cursor() as cur:
80
81
  c_time = r.create_time
@@ -1,8 +1,10 @@
1
1
  import datetime, time
2
2
  import urllib.parse
3
- import asyncio
3
+ import pathlib
4
4
  import functools
5
5
  import hashlib
6
+ import aiofiles
7
+ import asyncio
6
8
  from asyncio import Lock
7
9
  from collections import OrderedDict
8
10
  from concurrent.futures import ThreadPoolExecutor
@@ -11,6 +13,12 @@ from functools import wraps, partial
11
13
  from uuid import uuid4
12
14
  import os
13
15
 
16
+ async def copy_file(source: str|pathlib.Path, destination: str|pathlib.Path):
17
+ async with aiofiles.open(source, mode='rb') as src:
18
+ async with aiofiles.open(destination, mode='wb') as dest:
19
+ while chunk := await src.read(1024):
20
+ await dest.write(chunk)
21
+
14
22
  def hash_credential(username: str, password: str):
15
23
  return hashlib.sha256((username + password).encode()).hexdigest()
16
24
 
@@ -47,13 +55,15 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
47
55
  """
48
56
  def debounce_wrap(func):
49
57
  task_record: tuple[str, asyncio.Task] | None = None
58
+ fn_execution_lock = Lock()
50
59
  last_execution_time = 0
51
60
 
52
61
  async def delayed_func(*args, **kwargs):
53
62
  nonlocal last_execution_time
54
63
  await asyncio.sleep(delay)
55
- await func(*args, **kwargs)
56
- last_execution_time = time.monotonic()
64
+ async with fn_execution_lock:
65
+ await func(*args, **kwargs)
66
+ last_execution_time = time.monotonic()
57
67
 
58
68
  @functools.wraps(func)
59
69
  async def wrapper(*args, **kwargs):
@@ -64,10 +74,11 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
64
74
  task_record[1].cancel()
65
75
  g_debounce_tasks.pop(task_record[0], None)
66
76
 
67
- if time.monotonic() - last_execution_time > max_wait:
68
- await func(*args, **kwargs)
69
- last_execution_time = time.monotonic()
70
- return
77
+ async with fn_execution_lock:
78
+ if time.monotonic() - last_execution_time > max_wait:
79
+ await func(*args, **kwargs)
80
+ last_execution_time = time.monotonic()
81
+ return
71
82
 
72
83
  task = asyncio.create_task(delayed_func(*args, **kwargs))
73
84
  task_uid = uuid4().hex
lfss/sql/init.sql CHANGED
@@ -27,6 +27,15 @@ CREATE TABLE IF NOT EXISTS usize (
27
27
  size INTEGER DEFAULT 0
28
28
  );
29
29
 
30
+ CREATE TABLE IF NOT EXISTS upeer (
31
+ src_user_id INTEGER NOT NULL,
32
+ dst_user_id INTEGER NOT NULL,
33
+ access_level INTEGER DEFAULT 0,
34
+ PRIMARY KEY(src_user_id, dst_user_id),
35
+ FOREIGN KEY(src_user_id) REFERENCES user(id),
36
+ FOREIGN KEY(dst_user_id) REFERENCES user(id)
37
+ );
38
+
30
39
  CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url);
31
40
 
32
41
  CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
lfss/svc/app.py ADDED
@@ -0,0 +1,9 @@
1
+ from .app_native import *
2
+ import os
3
+
4
+ # order matters
5
+ app.include_router(router_api)
6
+ if os.environ.get("LFSS_WEBDAV", "0") == "1":
7
+ from .app_dav import *
8
+ app.include_router(router_dav)
9
+ app.include_router(router_fs)