lfss 0.8.3__py3-none-any.whl → 0.9.0__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.
lfss/src/database.py CHANGED
@@ -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,7 +13,8 @@ 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
@@ -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
 
@@ -552,7 +614,7 @@ class Database:
552
614
  if r is None:
553
615
  raise PathNotFoundError(f"File {url} not found")
554
616
  if op_user is not None:
555
- if r.owner_id != op_user.id and not op_user.is_admin:
617
+ if await check_path_permission(url, op_user) < AccessLevel.WRITE:
556
618
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot update file {url}")
557
619
  await fconn.update_file_record(url, permission=permission)
558
620
 
@@ -576,60 +638,70 @@ class Database:
576
638
  user_size_used = await fconn_r.user_size(user.id)
577
639
 
578
640
  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'
641
+
642
+ async with aiofiles.tempfile.SpooledTemporaryFile(max_size=MAX_MEM_FILE_BYTES) as f:
643
+ async for chunk in blob_stream:
644
+ await f.write(chunk)
645
+ file_size = await f.tell()
646
+ if user_size_used + file_size > user.max_storage:
647
+ raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
648
+
649
+ # check mime type
650
+ if mime_type is None:
651
+ mime_type, _ = mimetypes.guess_type(url)
652
+ if mime_type is None:
594
653
  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)
654
+ mime_type = mimesniff.what(await f.read(1024))
655
+ if mime_type is None:
656
+ mime_type = 'application/octet-stream'
657
+ await f.seek(0)
658
+
659
+ if file_size < LARGE_FILE_BYTES:
660
+ blob = await f.read()
661
+ async with transaction() as w_cur:
662
+ fconn_w = FileConn(w_cur)
663
+ await fconn_w.set_file_blob(f_id, blob)
664
+ await fconn_w.set_file_record(
665
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
666
+ permission=permission, external=False, mime_type=mime_type)
667
+
668
+ else:
669
+ async def blob_stream_tempfile():
670
+ nonlocal f
671
+ while True:
672
+ chunk = await f.read(CHUNK_SIZE)
673
+ if not chunk: break
674
+ yield chunk
675
+ await FileConn.set_file_blob_external(f_id, blob_stream_tempfile())
676
+ async with transaction() as w_cur:
677
+ await FileConn(w_cur).set_file_record(
678
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
679
+ permission=permission, external=True, mime_type=mime_type)
617
680
  return file_size
618
681
 
619
682
  async def read_file(self, url: str, start_byte = -1, end_byte = -1) -> AsyncIterable[bytes]:
620
- # end byte is exclusive: [start_byte, end_byte)
683
+ """
684
+ Read a file from the database.
685
+ end byte is exclusive: [start_byte, end_byte)
686
+ """
687
+ # The implementation is tricky, should not keep the cursor open for too long
621
688
  validate_url(url)
622
689
  async with unique_cursor() as cur:
623
690
  fconn = FileConn(cur)
624
691
  r = await fconn.get_file_record(url)
625
692
  if r is None:
626
693
  raise FileNotFoundError(f"File {url} not found")
694
+
627
695
  if r.external:
628
- ret = fconn.get_file_blob_external(r.file_id, start_byte=start_byte, end_byte=end_byte)
696
+ _blob_stream = fconn.get_file_blob_external(r.file_id, start_byte=start_byte, end_byte=end_byte)
697
+ async def blob_stream():
698
+ async for chunk in _blob_stream:
699
+ yield chunk
629
700
  else:
701
+ blob = await fconn.get_file_blob(r.file_id, start_byte=start_byte, end_byte=end_byte)
630
702
  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()
703
+ yield blob
704
+ ret = blob_stream()
633
705
  return ret
634
706
 
635
707
  async def delete_file(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
@@ -641,7 +713,8 @@ class Database:
641
713
  if r is None:
642
714
  return None
643
715
  if op_user is not None:
644
- if r.owner_id != op_user.id and not op_user.is_admin:
716
+ if r.owner_id != op_user.id and \
717
+ await check_path_permission(r.url, op_user, cursor=cur) < AccessLevel.WRITE:
645
718
  # will rollback
646
719
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot delete file {url}")
647
720
  f_id = r.file_id
@@ -661,7 +734,7 @@ class Database:
661
734
  if r is None:
662
735
  raise FileNotFoundError(f"File {old_url} not found")
663
736
  if op_user is not None:
664
- if r.owner_id != op_user.id:
737
+ if await check_path_permission(old_url, op_user) < AccessLevel.WRITE:
665
738
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
666
739
  await fconn.move_file(old_url, new_url)
667
740
 
@@ -681,20 +754,14 @@ class Database:
681
754
  assert old_url.endswith('/'), "Old path must end with /"
682
755
  assert new_url.endswith('/'), "New path must end with /"
683
756
 
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}")
757
+ async with unique_cursor() as cur:
758
+ if not (
759
+ await check_path_permission(old_url, op_user) >= AccessLevel.WRITE and
760
+ await check_path_permission(new_url, op_user) >= AccessLevel.WRITE
761
+ ):
762
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move path {old_url} to {new_url}")
697
763
 
764
+ async with transaction() as cur:
698
765
  fconn = FileConn(cur)
699
766
  await fconn.move_path(old_url, new_url, 'overwrite', op_user.id)
700
767
 
@@ -718,7 +785,7 @@ class Database:
718
785
 
719
786
  async def delete_path(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
720
787
  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
788
+ 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
789
 
723
790
  async with transaction() as cur:
724
791
  fconn = FileConn(cur)
@@ -786,7 +853,15 @@ class Database:
786
853
  buffer.seek(0)
787
854
  return buffer
788
855
 
789
- def check_user_permission(user: UserRecord, owner: UserRecord, file: FileRecord) -> tuple[bool, str]:
856
+ def check_file_read_permission(user: UserRecord, owner: UserRecord, file: FileRecord) -> tuple[bool, str]:
857
+ """
858
+ This does not consider alias level permission,
859
+ use check_path_permission for alias level permission check first:
860
+ ```
861
+ if await check_path_permission(path, user) < AccessLevel.READ:
862
+ read_allowed, reason = check_file_read_permission(user, owner, file)
863
+ ```
864
+ """
790
865
  if user.is_admin:
791
866
  return True, ""
792
867
 
@@ -812,4 +887,47 @@ def check_user_permission(user: UserRecord, owner: UserRecord, file: FileRecord)
812
887
  else:
813
888
  assert owner.permission == FileReadPermission.PUBLIC or owner.permission == FileReadPermission.UNSET
814
889
 
815
- return True, ""
890
+ return True, ""
891
+
892
+ async def check_path_permission(path: str, user: UserRecord, cursor: Optional[aiosqlite.Cursor] = None) -> AccessLevel:
893
+ """
894
+ Check if the user has access to the path.
895
+ If the user is admin, the user will have all access.
896
+ If the path is a file, the user will have all access if the user is the owner.
897
+ Otherwise, the user will have alias level access w.r.t. the path user.
898
+ """
899
+ if user.id == 0:
900
+ return AccessLevel.GUEST
901
+
902
+ @asynccontextmanager
903
+ async def this_cur():
904
+ if cursor is None:
905
+ async with unique_cursor() as _cur:
906
+ yield _cur
907
+ else:
908
+ yield cursor
909
+
910
+ # check if path user exists
911
+ path_username = path.split('/')[0]
912
+ async with this_cur() as cur:
913
+ uconn = UserConn(cur)
914
+ path_user = await uconn.get_user(path_username)
915
+ if path_user is None:
916
+ raise PathNotFoundError(f"Invalid path: {path_username} is not a valid username")
917
+
918
+ # check if user is admin
919
+ if user.is_admin or user.username == path_username:
920
+ return AccessLevel.ALL
921
+
922
+ # if the path is a file, check if the user is the owner
923
+ if not path.endswith('/'):
924
+ async with this_cur() as cur:
925
+ fconn = FileConn(cur)
926
+ file = await fconn.get_file_record(path)
927
+ if file and file.owner_id == user.id:
928
+ return AccessLevel.ALL
929
+
930
+ # check alias level
931
+ async with this_cur() as cur:
932
+ uconn = UserConn(cur)
933
+ return await uconn.query_peer_level(user.id, path_user.id)
lfss/src/datatype.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from enum import IntEnum
2
2
  import dataclasses, typing
3
+ from .utils import fmt_storage_size
3
4
 
4
5
  class FileReadPermission(IntEnum):
5
6
  UNSET = 0 # not set
@@ -7,6 +8,13 @@ class FileReadPermission(IntEnum):
7
8
  PROTECTED = 2 # accessible by any user
8
9
  PRIVATE = 3 # accessible by owner only (including admin)
9
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
+
10
18
  @dataclasses.dataclass
11
19
  class UserRecord:
12
20
  id: int
@@ -18,8 +26,12 @@ class UserRecord:
18
26
  max_storage: int
19
27
  permission: 'FileReadPermission'
20
28
 
29
+ def __post_init__(self):
30
+ self.permission = FileReadPermission(self.permission)
31
+
21
32
  def __str__(self):
22
- return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}, storage={self.max_storage}, permission={self.permission})"
33
+ return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}, " + \
34
+ f"storage={fmt_storage_size(self.max_storage)}, permission={self.permission.name})"
23
35
 
24
36
  @dataclasses.dataclass
25
37
  class FileRecord:
@@ -33,9 +45,12 @@ class FileRecord:
33
45
  external: bool
34
46
  mime_type: str
35
47
 
48
+ def __post_init__(self):
49
+ self.permission = FileReadPermission(self.permission)
50
+
36
51
  def __str__(self):
37
52
  return f"File {self.url} [{self.mime_type}] (owner={self.owner_id}, created at {self.create_time}, accessed at {self.access_time}, " + \
38
- f"file_id={self.file_id}, permission={self.permission}, size={self.file_size}, external={self.external})"
53
+ f"file_id={self.file_id}, permission={self.permission.name}, size={fmt_storage_size(self.file_size)}, external={self.external})"
39
54
 
40
55
  @dataclasses.dataclass
41
56
  class DirectoryRecord:
@@ -47,7 +62,7 @@ class DirectoryRecord:
47
62
  n_files: int = -1
48
63
 
49
64
  def __str__(self):
50
- return f"Directory {self.url} (size={self.size}, created at {self.create_time}, updated at {self.update_time}, accessed at {self.access_time}, n_files={self.n_files})"
65
+ return f"Directory {self.url} (size={fmt_storage_size(self.size)}, created at {self.create_time}, updated at {self.update_time}, accessed at {self.access_time}, n_files={self.n_files})"
51
66
 
52
67
  @dataclasses.dataclass
53
68
  class PathContents:
lfss/src/error.py CHANGED
@@ -1,6 +1,9 @@
1
+ import sqlite3
1
2
 
2
3
  class LFSSExceptionBase(Exception):...
3
4
 
5
+ class DatabaseLockedError(LFSSExceptionBase, sqlite3.DatabaseError):...
6
+
4
7
  class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
5
8
 
6
9
  class PermissionDeniedError(LFSSExceptionBase, PermissionError):...