lfss 0.12.3__py3-none-any.whl → 0.13.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.
- docs/changelog.md +17 -0
- frontend/api.js +90 -9
- frontend/base.css +33 -0
- frontend/edit.css +102 -0
- frontend/edit.html +29 -0
- frontend/edit.js +130 -0
- frontend/login.css +1 -0
- frontend/scripts.js +50 -3
- frontend/styles.css +10 -34
- lfss/api/__init__.py +9 -200
- lfss/api/bundle.py +201 -0
- lfss/api/connector.py +56 -52
- lfss/cli/__init__.py +8 -1
- lfss/cli/cli.py +57 -8
- lfss/cli/cli_lib.py +2 -4
- lfss/eng/database.py +125 -64
- lfss/eng/datatype.py +19 -0
- lfss/eng/error.py +14 -2
- lfss/svc/app.py +2 -0
- lfss/svc/app_base.py +6 -2
- lfss/svc/app_native.py +35 -21
- lfss/svc/app_native_user.py +28 -0
- lfss/svc/common_impl.py +30 -9
- {lfss-0.12.3.dist-info → lfss-0.13.0.dist-info}/METADATA +1 -1
- {lfss-0.12.3.dist-info → lfss-0.13.0.dist-info}/RECORD +27 -21
- {lfss-0.12.3.dist-info → lfss-0.13.0.dist-info}/WHEEL +0 -0
- {lfss-0.12.3.dist-info → lfss-0.13.0.dist-info}/entry_points.txt +0 -0
lfss/cli/cli.py
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
from pathlib import Path
|
2
2
|
import argparse, typing, sys
|
3
|
-
from lfss.api import
|
3
|
+
from lfss.api import Client, upload_directory, upload_file, download_file, download_directory
|
4
4
|
from lfss.eng.datatype import (
|
5
5
|
FileReadPermission, AccessLevel,
|
6
6
|
FileSortKey, DirSortKey,
|
@@ -10,6 +10,7 @@ from lfss.eng.utils import decode_uri_components, fmt_storage_size
|
|
10
10
|
|
11
11
|
from . import catch_request_error, line_sep
|
12
12
|
from .cli_lib import mimetype_unicode, stream_text
|
13
|
+
from ..eng.bounded_pool import BoundedThreadPoolExecutor
|
13
14
|
|
14
15
|
def parse_permission(s: str) -> FileReadPermission:
|
15
16
|
for p in FileReadPermission:
|
@@ -59,9 +60,9 @@ def print_path_list(
|
|
59
60
|
if not detailed:
|
60
61
|
if isinstance(r, DirectoryRecord):
|
61
62
|
assert r.url.endswith("/")
|
62
|
-
print(
|
63
|
+
print(r.name(), end="/")
|
63
64
|
else:
|
64
|
-
print(
|
65
|
+
print(r.name(), end="")
|
65
66
|
else:
|
66
67
|
print(decode_uri_components(r.url), end="")
|
67
68
|
if isinstance(r, FileRecord):
|
@@ -107,10 +108,26 @@ def parse_arguments():
|
|
107
108
|
sp_download.add_argument("--conflict", choices=["overwrite", "skip"], default="abort", help="Conflict resolution, only works with file download")
|
108
109
|
sp_download.add_argument("--retries", type=int, default=0, help="Number of retries")
|
109
110
|
|
111
|
+
# move
|
112
|
+
sp_move = sp.add_parser("move", help="Move or rename a file or directory", aliases=["mv"])
|
113
|
+
sp_move.add_argument("src", help="Source url path", type=str)
|
114
|
+
sp_move.add_argument("dst", help="Destination url path. If the destination exists, will raise an error. " , type=str)
|
115
|
+
|
116
|
+
# copy
|
117
|
+
sp_copy = sp.add_parser("copy", help="Copy a file or directory", aliases=["cp"])
|
118
|
+
sp_copy.add_argument("src", help="Source url path", type=str)
|
119
|
+
sp_copy.add_argument("dst", help="Destination url path. If the destination exists, will raise an error. ", type=str)
|
120
|
+
|
110
121
|
# query
|
111
122
|
sp_query = sp.add_parser("info", help="Query file or directories metadata from the server", aliases=["i"])
|
112
123
|
sp_query.add_argument("path", help="Path to query", nargs="+", type=str)
|
113
124
|
|
125
|
+
# set permission
|
126
|
+
sp_permission = sp.add_parser("set-permission", help="Set file or directory permission", aliases=["perm"])
|
127
|
+
sp_permission.add_argument("path", help="Path to set permission", type=str, nargs="+")
|
128
|
+
sp_permission.add_argument("permission", help="New permission to set", choices=[p.name.lower() for p in FileReadPermission])
|
129
|
+
sp_permission.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent permission setting")
|
130
|
+
|
114
131
|
# delete
|
115
132
|
sp_delete = sp.add_parser("delete", help="Delete files or directories", aliases=["del", "rm"])
|
116
133
|
sp_delete.add_argument("path", help="Path to delete", nargs="+", type=str)
|
@@ -152,7 +169,7 @@ def parse_arguments():
|
|
152
169
|
|
153
170
|
def main():
|
154
171
|
args = parse_arguments()
|
155
|
-
connector =
|
172
|
+
connector = Client()
|
156
173
|
if args.command == "whoami":
|
157
174
|
with catch_request_error():
|
158
175
|
user = connector.whoami()
|
@@ -231,6 +248,16 @@ def main():
|
|
231
248
|
)
|
232
249
|
if not success:
|
233
250
|
print("\033[91mFailed to download: \033[0m", msg, file=sys.stderr)
|
251
|
+
|
252
|
+
elif args.command in ["move", "mv"]:
|
253
|
+
with catch_request_error(default_error_handler_dict(f"{args.src} -> {args.dst}")):
|
254
|
+
connector.move(args.src, args.dst)
|
255
|
+
print(f"\033[32mMoved\033[0m ({args.src} -> {args.dst})")
|
256
|
+
|
257
|
+
elif args.command in ["copy", "cp"]:
|
258
|
+
with catch_request_error(default_error_handler_dict(f"{args.src} -> {args.dst}")):
|
259
|
+
connector.copy(args.src, args.dst)
|
260
|
+
print(f"\033[32mCopied\033[0m ({args.src} -> {args.dst})")
|
234
261
|
|
235
262
|
elif args.command in ["delete", "del", "rm"]:
|
236
263
|
if not args.yes:
|
@@ -250,10 +277,32 @@ def main():
|
|
250
277
|
for path in args.path:
|
251
278
|
with catch_request_error(default_error_handler_dict(path)):
|
252
279
|
res = connector.get_meta(path)
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
280
|
+
print(res)
|
281
|
+
|
282
|
+
elif args.command in ["set-permission", "perm"]:
|
283
|
+
flist = []
|
284
|
+
for path in args.path:
|
285
|
+
if not path.endswith("/"):
|
286
|
+
flist.append(path)
|
287
|
+
else:
|
288
|
+
batch_size = 10_000
|
289
|
+
with catch_request_error(default_error_handler_dict(path), cleanup_fn=lambda: flist.clear()):
|
290
|
+
for offset in range(0, connector.count_files(path, flat=True), batch_size):
|
291
|
+
files = connector.list_files(path, offset=offset, limit=batch_size, flat=True)
|
292
|
+
flist.extend([f.url for f in files])
|
293
|
+
if len(flist)>1 and input(f"You are about to set permission of {len(flist)} files to {args.permission.upper()}. Are you sure? ([yes]/no): ").lower() not in ["", "y", "yes"]:
|
294
|
+
print("Aborted.")
|
295
|
+
exit(0)
|
296
|
+
with connector.session(args.jobs) as c, BoundedThreadPoolExecutor(args.jobs) as executor:
|
297
|
+
for f in flist:
|
298
|
+
executor.submit(
|
299
|
+
lambda p: (
|
300
|
+
c.set_file_permission(p, parse_permission(args.permission)),
|
301
|
+
print(f"\033[32mSet permission\033[0m ({p}) to {args.permission.upper()}")
|
302
|
+
), f
|
303
|
+
)
|
304
|
+
executor.shutdown(wait=True)
|
305
|
+
|
257
306
|
|
258
307
|
elif args.command in ["ls", "list"]:
|
259
308
|
with catch_request_error(default_error_handler_dict(args.path)):
|
lfss/cli/cli_lib.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
|
2
|
-
from ..api.connector import
|
2
|
+
from ..api.connector import Client
|
3
3
|
from ..eng.datatype import DirectoryRecord, FileRecord
|
4
4
|
|
5
5
|
def mimetype_unicode(r: DirectoryRecord | FileRecord):
|
@@ -37,7 +37,7 @@ def mimetype_unicode(r: DirectoryRecord | FileRecord):
|
|
37
37
|
return "📄"
|
38
38
|
|
39
39
|
def stream_text(
|
40
|
-
conn:
|
40
|
+
conn: Client,
|
41
41
|
path: str,
|
42
42
|
encoding="utf-8",
|
43
43
|
chunk_size=1024 * 8,
|
@@ -51,8 +51,6 @@ def stream_text(
|
|
51
51
|
"""
|
52
52
|
MAX_TEXT_SIZE = 100 * 1024 * 1024 # 100 MB
|
53
53
|
r = conn.get_fmeta(path)
|
54
|
-
if r is None:
|
55
|
-
raise FileNotFoundError(f"File not found: {path}")
|
56
54
|
if r.file_size > MAX_TEXT_SIZE:
|
57
55
|
raise ValueError(f"File size {r.file_size} exceeds maximum text size {MAX_TEXT_SIZE}")
|
58
56
|
ss = conn.get_stream(r.url, chunk_size=chunk_size)
|
lfss/eng/database.py
CHANGED
@@ -85,14 +85,14 @@ class UserConn(DBObjectBase):
|
|
85
85
|
max_storage: int = 1073741824, permission: FileReadPermission = FileReadPermission.UNSET
|
86
86
|
) -> int:
|
87
87
|
def validate_username(username: str):
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
88
|
+
assert_or(not set(username) & {'/', ':'}, InvalidInputError("Invalid username"))
|
89
|
+
assert_or(not username.startswith('_'), InvalidInputError("Error: reserved username"))
|
90
|
+
assert_or(not (len(username) > 255), InvalidInputError("Username too long"))
|
91
|
+
assert_or(urllib.parse.quote(username) == username, InvalidInputError("Invalid username, must be URL safe"))
|
92
92
|
validate_username(username)
|
93
93
|
self.logger.debug(f"Creating user {username}")
|
94
94
|
credential = hash_credential(username, password)
|
95
|
-
|
95
|
+
assert_or(await self.get_user(username) is None, InvalidDataError(f"Duplicate username: {username}"))
|
96
96
|
await self.cur.execute("INSERT INTO user (username, credential, is_admin, max_storage, permission) VALUES (?, ?, ?, ?, ?)", (username, credential, is_admin, max_storage, permission))
|
97
97
|
self.logger.info(f"User {username} created")
|
98
98
|
assert self.cur.lastrowid is not None
|
@@ -102,9 +102,9 @@ class UserConn(DBObjectBase):
|
|
102
102
|
self, username: str, password: Optional[str] = None, is_admin: Optional[bool] = None,
|
103
103
|
max_storage: Optional[int] = None, permission: Optional[FileReadPermission] = None
|
104
104
|
):
|
105
|
-
|
106
|
-
|
107
|
-
|
105
|
+
assert_or(not username.startswith('_'), InvalidInputError("Error: reserved username"))
|
106
|
+
assert_or(not ('/' in username or len(username) > 255), InvalidInputError("Invalid username"))
|
107
|
+
assert_or(urllib.parse.quote(username) == username, InvalidInputError("Invalid username, must be URL safe"))
|
108
108
|
|
109
109
|
current_record = await self.get_user(username)
|
110
110
|
if current_record is None:
|
@@ -169,7 +169,8 @@ class UserConn(DBObjectBase):
|
|
169
169
|
List all users that user can do [AliasLevel] to, with level >= level,
|
170
170
|
else:
|
171
171
|
List all users that can do [AliasLevel] to user, with level >= level
|
172
|
-
|
172
|
+
|
173
|
+
Note: the returned list does not include the user and is not apporiate for admin (who has all permissions for all users)
|
173
174
|
"""
|
174
175
|
assert int(level) > AccessLevel.NONE, f"Invalid level, {level}"
|
175
176
|
aim_field = 'src_user_id' if incoming else 'dst_user_id'
|
@@ -297,6 +298,12 @@ class FileConn(DBObjectBase):
|
|
297
298
|
dirs = [await get_dir(url + d) for d in dirs_str]
|
298
299
|
return dirs
|
299
300
|
|
301
|
+
async def is_dir_exist(self, url: str) -> bool:
|
302
|
+
if not url.endswith('/'): url += '/'
|
303
|
+
cursor = await self.cur.execute("SELECT 1 FROM fmeta WHERE url LIKE ? ESCAPE '\\' LIMIT 1", (self.escape_sqlike(url) + '%', ))
|
304
|
+
res = await cursor.fetchone()
|
305
|
+
return res is not None
|
306
|
+
|
300
307
|
async def count_dir_files(self, url: str, flat: bool = False):
|
301
308
|
if not url.endswith('/'): url += '/'
|
302
309
|
if url == '/': url = ''
|
@@ -463,8 +470,8 @@ class FileConn(DBObjectBase):
|
|
463
470
|
Copy all files under old_url to new_url,
|
464
471
|
if user_id is None, will not change the owner_id of the files. Otherwise, will change the owner_id to user_id.
|
465
472
|
"""
|
466
|
-
|
467
|
-
|
473
|
+
assert_or(old_url.endswith('/'), InvalidInputError("Old path must end with /"))
|
474
|
+
assert_or(new_url.endswith('/'), InvalidInputError("New path must end with /"))
|
468
475
|
cursor = await self.cur.execute(
|
469
476
|
"SELECT * FROM fmeta WHERE url LIKE ? ESCAPE '\\'",
|
470
477
|
(self.escape_sqlike(old_url) + '%', )
|
@@ -484,37 +491,54 @@ class FileConn(DBObjectBase):
|
|
484
491
|
await self._user_size_inc(user_id, old_record.file_size)
|
485
492
|
self.logger.info(f"Copied path {old_url} to {new_url}")
|
486
493
|
|
487
|
-
async def move_file(self, old_url: str, new_url: str):
|
494
|
+
async def move_file(self, old_url: str, new_url: str, transfer_to_user: Optional[int] = None):
|
488
495
|
old = await self.get_file_record(old_url)
|
489
496
|
if old is None:
|
490
497
|
raise FileNotFoundError(f"File {old_url} not found")
|
491
498
|
new_exists = await self.get_file_record(new_url)
|
492
499
|
if new_exists is not None:
|
493
500
|
raise FileExistsError(f"File {new_url} already exists")
|
494
|
-
await self.cur.execute(
|
501
|
+
await self.cur.execute(
|
502
|
+
"UPDATE fmeta SET url = ?, owner_id = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?",
|
503
|
+
(new_url, old.owner_id if transfer_to_user is None else transfer_to_user, old_url)
|
504
|
+
)
|
505
|
+
if transfer_to_user is not None and transfer_to_user != old.owner_id:
|
506
|
+
await self._user_size_dec(old.owner_id, old.file_size)
|
507
|
+
await self._user_size_inc(transfer_to_user, old.file_size)
|
495
508
|
self.logger.info(f"Moved file {old_url} to {new_url}")
|
496
509
|
|
497
|
-
async def
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
510
|
+
async def transfer_ownership(self, url: str, new_owner: int):
|
511
|
+
old = await self.get_file_record(url)
|
512
|
+
if old is None:
|
513
|
+
raise FileNotFoundError(f"File {url} not found")
|
514
|
+
if new_owner == old.owner_id:
|
515
|
+
return
|
516
|
+
await self.cur.execute("UPDATE fmeta SET owner_id = ? WHERE url = ?", (new_owner, url))
|
517
|
+
await self._user_size_dec(old.owner_id, old.file_size)
|
518
|
+
await self._user_size_inc(new_owner, old.file_size)
|
519
|
+
self.logger.info(f"Transferred ownership of file {url} from user {old.owner_id} to user {new_owner}")
|
520
|
+
|
521
|
+
async def move_dir(self, old_url: str, new_url: str, transfer_to_user: Optional[int] = None):
|
522
|
+
assert_or(old_url.endswith('/'), InvalidInputError("Old path must end with /"))
|
523
|
+
assert_or(new_url.endswith('/'), InvalidInputError("New path must end with /"))
|
524
|
+
cursor = await self.cur.execute(
|
525
|
+
"SELECT url, owner_id, file_size FROM fmeta WHERE url LIKE ? ESCAPE '\\'",
|
526
|
+
(self.escape_sqlike(old_url) + '%', )
|
527
|
+
)
|
528
|
+
res = await cursor.fetchall()
|
512
529
|
for r in res:
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
530
|
+
r_url, r_user, r_size = r
|
531
|
+
new_url_full = new_url + r_url[len(old_url):]
|
532
|
+
if await (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_url_full, ))).fetchone():
|
533
|
+
self.logger.error(f"File {new_url_full} already exists on move path: {old_url} -> {new_url}")
|
534
|
+
raise FileDuplicateError(f"File {new_url_full} already exists")
|
535
|
+
await self.cur.execute(
|
536
|
+
"UPDATE fmeta SET url = ?, owner_id = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?",
|
537
|
+
(new_url_full, r_user if transfer_to_user is None else transfer_to_user, r_url)
|
538
|
+
)
|
539
|
+
if transfer_to_user is not None and transfer_to_user != r_user:
|
540
|
+
await self._user_size_dec(r_user, r_size)
|
541
|
+
await self._user_size_inc(transfer_to_user, r_size)
|
518
542
|
|
519
543
|
async def log_access(self, url: str):
|
520
544
|
await self.cur.execute("UPDATE fmeta SET access_time = CURRENT_TIMESTAMP WHERE url = ?", (url, ))
|
@@ -529,17 +553,13 @@ class FileConn(DBObjectBase):
|
|
529
553
|
self.logger.info(f"Deleted fmeta {url}")
|
530
554
|
return file_record
|
531
555
|
|
532
|
-
async def
|
533
|
-
"""
|
556
|
+
async def list_user_file_records(self, owner_id: int) -> list[FileRecord]:
|
557
|
+
""" list all records with owner_id """
|
534
558
|
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, ))
|
535
559
|
res = await cursor.fetchall()
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
self.logger.info(f"Deleted {len(ret)} file records for user {owner_id}") # type: ignore
|
540
|
-
return ret
|
541
|
-
|
542
|
-
async def delete_records_by_prefix(self, path: str, under_owner_id: Optional[int] = None) -> list[FileRecord]:
|
560
|
+
return [self.parse_record(r) for r in res]
|
561
|
+
|
562
|
+
async def delete_records_by_prefix(self, path: str) -> list[FileRecord]:
|
543
563
|
"""Delete all records with url starting with path"""
|
544
564
|
# update user size
|
545
565
|
cursor = await self.cur.execute(
|
@@ -559,10 +579,7 @@ class FileConn(DBObjectBase):
|
|
559
579
|
# if any new records are created here, the size update may be inconsistent
|
560
580
|
# but it's not a big deal... we should have only one writer
|
561
581
|
|
562
|
-
|
563
|
-
res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? ESCAPE '\\' RETURNING *", (self.escape_sqlike(path) + '%', ))
|
564
|
-
else:
|
565
|
-
res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? ESCAPE '\\' AND owner_id = ? RETURNING *", (self.escape_sqlike(path) + '%', under_owner_id))
|
582
|
+
res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? ESCAPE '\\' RETURNING *", (self.escape_sqlike(path) + '%', ))
|
566
583
|
all_f_rec = await res.fetchall()
|
567
584
|
self.logger.info(f"Deleted {len(all_f_rec)} file(s) for path {path}") # type: ignore
|
568
585
|
return [self.parse_record(r) for r in all_f_rec]
|
@@ -959,7 +976,13 @@ class Database:
|
|
959
976
|
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
|
960
977
|
if await check_path_permission(new_url, op_user, cursor=cur) < AccessLevel.WRITE:
|
961
978
|
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file to {new_url}")
|
962
|
-
await fconn.move_file(old_url, new_url)
|
979
|
+
await fconn.move_file(old_url, new_url, transfer_to_user=op_user.id if op_user is not None else None)
|
980
|
+
|
981
|
+
# check user size limit if transferring ownership
|
982
|
+
if op_user is not None:
|
983
|
+
user_size_used = await fconn.user_size(op_user.id)
|
984
|
+
if user_size_used > op_user.max_storage:
|
985
|
+
raise StorageExceededError(f"Unable to move file, user size limit exceeded: {user_size_used} > {op_user.max_storage}")
|
963
986
|
|
964
987
|
new_mime, _ = mimetypes.guess_type(new_url)
|
965
988
|
if not new_mime is None:
|
@@ -981,6 +1004,12 @@ class Database:
|
|
981
1004
|
if await check_path_permission(new_url, op_user, cursor=cur) < AccessLevel.WRITE:
|
982
1005
|
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot copy file to {new_url}")
|
983
1006
|
await fconn.copy_file(old_url, new_url, user_id=op_user.id if op_user is not None else None)
|
1007
|
+
|
1008
|
+
# check user size limit if transferring ownership
|
1009
|
+
if op_user is not None:
|
1010
|
+
user_size_used = await fconn.user_size(op_user.id)
|
1011
|
+
if user_size_used > op_user.max_storage:
|
1012
|
+
raise StorageExceededError(f"Unable to copy file, user size limit exceeded: {user_size_used} > {op_user.max_storage}")
|
984
1013
|
|
985
1014
|
async def move_dir(self, old_url: str, new_url: str, op_user: UserRecord):
|
986
1015
|
validate_url(old_url, 'dir')
|
@@ -990,9 +1019,9 @@ class Database:
|
|
990
1019
|
new_url = new_url[1:]
|
991
1020
|
if old_url.startswith('/'):
|
992
1021
|
old_url = old_url[1:]
|
993
|
-
|
994
|
-
|
995
|
-
|
1022
|
+
assert_or(old_url != new_url, InvalidPathError("Old and new path must be different"))
|
1023
|
+
assert_or(old_url.endswith('/'), InvalidPathError("Old path must end with /"))
|
1024
|
+
assert_or(new_url.endswith('/'), InvalidPathError("New path must end with /"))
|
996
1025
|
|
997
1026
|
async with unique_cursor() as cur:
|
998
1027
|
if not (
|
@@ -1004,6 +1033,12 @@ class Database:
|
|
1004
1033
|
async with transaction() as cur:
|
1005
1034
|
fconn = FileConn(cur)
|
1006
1035
|
await fconn.move_dir(old_url, new_url, op_user.id)
|
1036
|
+
|
1037
|
+
# check user size limit
|
1038
|
+
user_size_used = await fconn.user_size(op_user.id)
|
1039
|
+
if user_size_used > op_user.max_storage:
|
1040
|
+
raise StorageExceededError(f"Unable to move path, user size limit exceeded: {user_size_used} > {op_user.max_storage}")
|
1041
|
+
|
1007
1042
|
|
1008
1043
|
async def copy_dir(self, old_url: str, new_url: str, op_user: UserRecord):
|
1009
1044
|
validate_url(old_url, 'dir')
|
@@ -1013,9 +1048,9 @@ class Database:
|
|
1013
1048
|
new_url = new_url[1:]
|
1014
1049
|
if old_url.startswith('/'):
|
1015
1050
|
old_url = old_url[1:]
|
1016
|
-
|
1017
|
-
|
1018
|
-
|
1051
|
+
assert_or(old_url != new_url, InvalidPathError("Old and new path must be different"))
|
1052
|
+
assert_or(old_url.endswith('/'), InvalidPathError("Old path must end with /"))
|
1053
|
+
assert_or(new_url.endswith('/'), InvalidPathError("New path must end with /"))
|
1019
1054
|
|
1020
1055
|
async with unique_cursor() as cur:
|
1021
1056
|
if not (
|
@@ -1028,7 +1063,12 @@ class Database:
|
|
1028
1063
|
fconn = FileConn(cur)
|
1029
1064
|
await fconn.copy_dir(old_url, new_url, op_user.id)
|
1030
1065
|
|
1031
|
-
|
1066
|
+
# check user size limit
|
1067
|
+
user_size_used = await fconn.user_size(op_user.id)
|
1068
|
+
if user_size_used > op_user.max_storage:
|
1069
|
+
raise StorageExceededError(f"Unable to copy path, user size limit exceeded: {user_size_used} > {op_user.max_storage}")
|
1070
|
+
|
1071
|
+
async def __batch_unlink_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
|
1032
1072
|
# https://github.com/langchain-ai/langchain/issues/10321
|
1033
1073
|
internal_ids = []
|
1034
1074
|
external_ids = []
|
@@ -1049,14 +1089,16 @@ class Database:
|
|
1049
1089
|
|
1050
1090
|
async def delete_dir(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
|
1051
1091
|
validate_url(url, 'dir')
|
1052
|
-
|
1092
|
+
if op_user is not None:
|
1093
|
+
if await check_path_permission(url, op_user) < AccessLevel.WRITE:
|
1094
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot delete path {url}")
|
1053
1095
|
|
1054
1096
|
async with transaction() as cur:
|
1055
1097
|
fconn = FileConn(cur)
|
1056
|
-
records = await fconn.delete_records_by_prefix(url
|
1098
|
+
records = await fconn.delete_records_by_prefix(url)
|
1057
1099
|
if not records:
|
1058
1100
|
return None
|
1059
|
-
await self.
|
1101
|
+
await self.__batch_unlink_file_blobs(fconn, records)
|
1060
1102
|
return records
|
1061
1103
|
|
1062
1104
|
async def delete_user(self, u: str | int):
|
@@ -1070,14 +1112,33 @@ class Database:
|
|
1070
1112
|
await uconn.delete_user(user.username)
|
1071
1113
|
|
1072
1114
|
fconn = FileConn(cur)
|
1073
|
-
records = await fconn.delete_user_file_records(user.id)
|
1074
|
-
self.logger.debug("Deleting files...")
|
1075
|
-
await self.__batch_delete_file_blobs(fconn, records)
|
1076
|
-
self.logger.info(f"Deleted {len(records)} file(s) for user {user.username}")
|
1077
1115
|
|
1078
1116
|
# make sure the user's directory is deleted,
|
1079
|
-
|
1080
|
-
|
1117
|
+
to_del_records = await fconn.delete_records_by_prefix(user.username + '/')
|
1118
|
+
|
1119
|
+
# transfer ownership of files outside the user's directory
|
1120
|
+
to_transfer_records = await fconn.list_user_file_records(user.id)
|
1121
|
+
__user_map: dict[str, UserRecord] = {}
|
1122
|
+
for r in to_transfer_records:
|
1123
|
+
r_username = r.url.split('/')[0]
|
1124
|
+
if not r_username in __user_map:
|
1125
|
+
r_user = await uconn.get_user(r_username)
|
1126
|
+
assert r_user is not None, f"User {r_username} not found"
|
1127
|
+
__user_map[r_username] = r_user
|
1128
|
+
r_user = __user_map[r_username]
|
1129
|
+
await fconn.transfer_ownership(r.url, r_user.id)
|
1130
|
+
|
1131
|
+
# check user size limit
|
1132
|
+
for r_user in __user_map.values():
|
1133
|
+
user_size_used = await fconn.user_size(r_user.id)
|
1134
|
+
if user_size_used > r_user.max_storage:
|
1135
|
+
raise StorageExceededError(f"Unable to transfer files, user size limit exceeded for {r_user.username}: {user_size_used} > {r_user.max_storage}")
|
1136
|
+
|
1137
|
+
self.logger.info(f"Transferred ownership of {len(to_transfer_records)} file(s) outside user {user.username}'s directory")
|
1138
|
+
|
1139
|
+
# release file blobs finally
|
1140
|
+
await self.__batch_unlink_file_blobs(fconn, to_del_records)
|
1141
|
+
self.logger.info(f"Deleted user {user.username} and {len(to_del_records)} file(s) under the user's directory")
|
1081
1142
|
|
1082
1143
|
async def iter_dir(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes | AsyncIterable[bytes]]]:
|
1083
1144
|
validate_url(top_url, 'dir')
|
@@ -1159,7 +1220,7 @@ async def _get_path_owner(cur: aiosqlite.Cursor, path: str) -> UserRecord:
|
|
1159
1220
|
uconn = UserConn(cur)
|
1160
1221
|
path_user = await uconn.get_user(path_username)
|
1161
1222
|
if path_user is None:
|
1162
|
-
raise
|
1223
|
+
raise PathNotFoundError(f"Path not found: {path_username} is not a valid username")
|
1163
1224
|
return path_user
|
1164
1225
|
|
1165
1226
|
async def check_file_read_permission(user: UserRecord, file: FileRecord, cursor: Optional[aiosqlite.Cursor] = None) -> tuple[bool, str]:
|
lfss/eng/datatype.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
from enum import IntEnum
|
2
2
|
import dataclasses, typing
|
3
|
+
import urllib.parse
|
3
4
|
from .utils import fmt_storage_size
|
4
5
|
|
5
6
|
class FileReadPermission(IntEnum):
|
@@ -32,6 +33,10 @@ class UserRecord:
|
|
32
33
|
def __str__(self):
|
33
34
|
return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}, " + \
|
34
35
|
f"storage={fmt_storage_size(self.max_storage)}, permission={self.permission.name})"
|
36
|
+
|
37
|
+
def desensitize(self):
|
38
|
+
self.credential = "__HIDDEN__"
|
39
|
+
return self
|
35
40
|
|
36
41
|
@dataclasses.dataclass
|
37
42
|
class FileRecord:
|
@@ -45,7 +50,12 @@ class FileRecord:
|
|
45
50
|
external: bool
|
46
51
|
mime_type: str
|
47
52
|
|
53
|
+
def name(self, raw: bool = False):
|
54
|
+
name = self.url.rsplit('/', 1)[-1]
|
55
|
+
return name if raw else urllib.parse.unquote(name)
|
56
|
+
|
48
57
|
def __post_init__(self):
|
58
|
+
assert not self.url.endswith('/'), "File URL should not end with '/'"
|
49
59
|
self.permission = FileReadPermission(self.permission)
|
50
60
|
|
51
61
|
def __str__(self):
|
@@ -61,6 +71,15 @@ class DirectoryRecord:
|
|
61
71
|
access_time: str = ""
|
62
72
|
n_files: int = -1
|
63
73
|
|
74
|
+
def name(self, raw: bool = False):
|
75
|
+
if self.url == "/" or self.url == "":
|
76
|
+
return ""
|
77
|
+
name = self.url.rstrip('/').rsplit('/', 1)[-1]
|
78
|
+
return name if raw else urllib.parse.unquote(name)
|
79
|
+
|
80
|
+
def __post_init__(self):
|
81
|
+
assert self.url.endswith('/'), "Directory URL should end with '/'"
|
82
|
+
|
64
83
|
def __str__(self):
|
65
84
|
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})"
|
66
85
|
|
lfss/eng/error.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
import sqlite3
|
2
|
+
from typing import Callable
|
2
3
|
|
3
4
|
class LFSSExceptionBase(Exception):...
|
4
5
|
|
@@ -8,7 +9,9 @@ class InvalidOptionsError(LFSSExceptionBase, ValueError):...
|
|
8
9
|
|
9
10
|
class InvalidDataError(LFSSExceptionBase, ValueError):...
|
10
11
|
|
11
|
-
class
|
12
|
+
class InvalidInputError(InvalidDataError):...
|
13
|
+
|
14
|
+
class InvalidPathError(InvalidDataError):...
|
12
15
|
|
13
16
|
class DatabaseLockedError(LFSSExceptionBase, sqlite3.DatabaseError):...
|
14
17
|
|
@@ -22,4 +25,13 @@ class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
|
|
22
25
|
|
23
26
|
class StorageExceededError(LFSSExceptionBase):...
|
24
27
|
|
25
|
-
class TooManyItemsError(LFSSExceptionBase):...
|
28
|
+
class TooManyItemsError(LFSSExceptionBase):...
|
29
|
+
|
30
|
+
def assert_or(condition: bool, exception: LFSSExceptionBase | Callable[[], LFSSExceptionBase]):
|
31
|
+
if not condition:
|
32
|
+
if isinstance(exception, Exception):
|
33
|
+
raise exception
|
34
|
+
elif callable(exception):
|
35
|
+
raise exception()
|
36
|
+
else:
|
37
|
+
raise TypeError("Exception must be an instance of LFSSExceptionBase or a callable returning one")
|
lfss/svc/app.py
CHANGED
lfss/svc/app_base.py
CHANGED
@@ -147,12 +147,16 @@ async def registered_user(user: UserRecord = Depends(get_current_user)):
|
|
147
147
|
return user
|
148
148
|
|
149
149
|
router_api = APIRouter(prefix="/_api")
|
150
|
+
router_user = APIRouter(prefix="/_api/user")
|
150
151
|
router_dav = APIRouter(prefix="")
|
151
152
|
router_fs = APIRouter(prefix="")
|
152
153
|
|
153
154
|
__all__ = [
|
154
155
|
"app", "db", "logger",
|
155
156
|
"handle_exception", "skip_request_log",
|
156
|
-
"
|
157
|
-
"
|
157
|
+
"get_current_user", "registered_user",
|
158
|
+
"router_api",
|
159
|
+
"router_user",
|
160
|
+
"router_fs",
|
161
|
+
"router_dav",
|
158
162
|
]
|