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.
- Readme.md +6 -1
- docs/Permission.md +38 -26
- docs/Webdav.md +22 -0
- frontend/api.js +21 -10
- lfss/api/__init__.py +3 -3
- lfss/api/connector.py +13 -7
- lfss/cli/balance.py +3 -3
- lfss/cli/cli.py +5 -10
- lfss/cli/panel.py +8 -0
- lfss/cli/serve.py +4 -2
- lfss/cli/user.py +31 -4
- lfss/cli/vacuum.py +5 -5
- lfss/{src → eng}/config.py +1 -0
- lfss/{src → eng}/connection_pool.py +22 -3
- lfss/{src → eng}/database.py +280 -67
- lfss/{src → eng}/datatype.py +7 -0
- lfss/{src → eng}/error.py +7 -0
- lfss/{src → eng}/thumb.py +10 -9
- lfss/{src → eng}/utils.py +18 -7
- lfss/sql/init.sql +9 -0
- lfss/svc/app.py +9 -0
- lfss/svc/app_base.py +152 -0
- lfss/svc/app_dav.py +374 -0
- lfss/svc/app_native.py +247 -0
- lfss/svc/common_impl.py +270 -0
- lfss/{src/stat.py → svc/request_log.py} +2 -2
- {lfss-0.8.4.dist-info → lfss-0.9.1.dist-info}/METADATA +9 -4
- lfss-0.9.1.dist-info/RECORD +49 -0
- lfss/src/server.py +0 -621
- lfss-0.8.4.dist-info/RECORD +0 -44
- /lfss/{src → eng}/__init__.py +0 -0
- /lfss/{src → eng}/bounded_pool.py +0 -0
- /lfss/{src → eng}/log.py +0 -0
- {lfss-0.8.4.dist-info → lfss-0.9.1.dist-info}/WHEEL +0 -0
- {lfss-0.8.4.dist-info → lfss-0.9.1.dist-info}/entry_points.txt +0 -0
lfss/{src → eng}/database.py
RENAMED
@@ -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,
|
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
|
-
|
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
|
|
@@ -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
|
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
|
-
|
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'
|
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
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
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
|
-
|
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
|
-
|
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
|
632
|
-
|
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
|
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
|
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
|
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}")
|
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
|
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)
|
lfss/{src → eng}/datatype.py
RENAMED
@@ -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
|
lfss/{src → eng}/error.py
RENAMED
@@ -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):...
|
lfss/{src → eng}/thumb.py
RENAMED
@@ -1,6 +1,6 @@
|
|
1
|
-
from lfss.
|
2
|
-
from lfss.
|
3
|
-
from lfss.
|
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
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
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
|
lfss/{src → eng}/utils.py
RENAMED
@@ -1,8 +1,10 @@
|
|
1
1
|
import datetime, time
|
2
2
|
import urllib.parse
|
3
|
-
import
|
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
|
-
|
56
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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);
|