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.
- Readme.md +12 -4
- docs/Permission.md +38 -26
- frontend/api.js +21 -10
- frontend/thumb.js +8 -4
- lfss/api/connector.py +10 -6
- lfss/cli/cli.py +6 -10
- lfss/cli/user.py +28 -1
- lfss/sql/init.sql +9 -0
- lfss/src/connection_pool.py +22 -3
- lfss/src/database.py +184 -66
- lfss/src/datatype.py +18 -3
- lfss/src/error.py +3 -0
- lfss/src/server.py +50 -67
- lfss/src/utils.py +9 -6
- {lfss-0.8.3.dist-info → lfss-0.9.0.dist-info}/METADATA +13 -5
- {lfss-0.8.3.dist-info → lfss-0.9.0.dist-info}/RECORD +18 -18
- {lfss-0.8.3.dist-info → lfss-0.9.0.dist-info}/WHEEL +0 -0
- {lfss-0.8.3.dist-info → lfss-0.9.0.dist-info}/entry_points.txt +0 -0
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,
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
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
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
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
|
-
|
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
|
-
|
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
|
632
|
-
|
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
|
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
|
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
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
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
|
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
|
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):...
|