lfss 0.12.2__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.
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 Connector, upload_directory, upload_file, download_file, download_directory
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(decode_uri_components(r.url).rstrip("/").split("/")[-1], end="/")
63
+ print(r.name(), end="/")
63
64
  else:
64
- print(decode_uri_components(r.url).split("/")[-1], end="")
65
+ print(r.name(), end="")
65
66
  else:
66
67
  print(decode_uri_components(r.url), end="")
67
68
  if isinstance(r, FileRecord):
@@ -107,12 +108,28 @@ 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
- sp_delete = sp.add_parser("delete", help="Delete files or directories", aliases=["del"])
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)
117
134
  sp_delete.add_argument("-y", "--yes", action="store_true", help="Confirm deletion without prompt")
118
135
 
@@ -152,7 +169,7 @@ def parse_arguments():
152
169
 
153
170
  def main():
154
171
  args = parse_arguments()
155
- connector = Connector()
172
+ connector = Client()
156
173
  if args.command == "whoami":
157
174
  with catch_request_error():
158
175
  user = connector.whoami()
@@ -227,12 +244,22 @@ def main():
227
244
  verbose=not args.quiet,
228
245
  n_retries=args.retries,
229
246
  interval=args.interval,
230
- overwrite=args.overwrite
247
+ overwrite=args.conflict == "overwrite"
231
248
  )
232
249
  if not success:
233
250
  print("\033[91mFailed to download: \033[0m", msg, file=sys.stderr)
234
251
 
235
- elif args.command in ["delete", "del"]:
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})")
261
+
262
+ elif args.command in ["delete", "del", "rm"]:
236
263
  if not args.yes:
237
264
  print("You are about to delete the following paths:")
238
265
  for path in args.path:
@@ -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
- if res is None:
254
- print(f"\033[31mNot found\033[0m ({path})")
255
- else:
256
- print(res)
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)):
@@ -265,9 +314,10 @@ def main():
265
314
  order_desc=args.reverse,
266
315
  )
267
316
  print_path_list(res, detailed=args.long)
268
- if len(res.dirs) + len(res.files) == args.limit:
269
- print(f"\033[33m[Warning] List limit reached, use --offset and --limit to list more items.\033[0m")
270
-
317
+ if args.path != "/" and len(res.dirs) + len(res.files) == args.limit:
318
+ _len_str = f"{args.offset + 1}-{args.offset + len(res.dirs) + len(res.files)}/{connector.count_dirs(args.path) + connector.count_files(args.path)}"
319
+ print(f"{_len_str} items listed.")
320
+
271
321
  elif args.command in ["lsf", "list-f"]:
272
322
  with catch_request_error(default_error_handler_dict(args.path)):
273
323
  res = connector.list_files(
@@ -279,8 +329,12 @@ def main():
279
329
  order_desc=args.reverse,
280
330
  )
281
331
  print_path_list(res, detailed=args.long)
282
- if len(res) == args.limit:
283
- print(f"\033[33m[Warning] List limit reached, use --offset and --limit to list more files.\033[0m")
332
+ if args.path != "/" and len(res) == args.limit:
333
+ if args.recursive:
334
+ _len_str = f"{args.offset + 1}-{args.offset + len(res)}/{connector.count_files(args.path, flat=True)}"
335
+ else:
336
+ _len_str = f"{args.offset + 1}-{args.offset + len(res)}/{connector.count_files(args.path)}"
337
+ print(f"{_len_str} files listed.")
284
338
 
285
339
  elif args.command in ["lsd", "list-d"]:
286
340
  with catch_request_error(default_error_handler_dict(args.path)):
@@ -293,8 +347,9 @@ def main():
293
347
  order_desc=args.reverse,
294
348
  )
295
349
  print_path_list(res, detailed=args.long)
296
- if len(res) == args.limit:
297
- print(f"\033[33m[Warning] List limit reached, use --offset and --limit to list more directories.\033[0m")
350
+ if args.path != "/" and len(res) == args.limit:
351
+ _len_str = f"{args.offset + 1}-{args.offset + len(res)}/{connector.count_dirs(args.path)}"
352
+ print(f"{_len_str} items listed.")
298
353
 
299
354
  elif args.command in ["cat", "concatenate"]:
300
355
  for _p in args.path:
lfss/cli/cli_lib.py CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- from ..api.connector import Connector
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: Connector,
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
- assert not set(username) & {'/', ':'}, "Invalid username"
89
- assert not username.startswith('_'), "Error: reserved username"
90
- assert not (len(username) > 255), "Username too long"
91
- assert urllib.parse.quote(username) == username, "Invalid username, must be URL safe"
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
- assert await self.get_user(username) is None, "Duplicate username"
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
- assert not username.startswith('_'), "Error: reserved username"
106
- assert not ('/' in username or len(username) > 255), "Invalid username"
107
- assert urllib.parse.quote(username) == username, "Invalid username, must be URL safe"
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
- Note: the returned list does not include user and is not apporiate for admin (who has all permissions for all users)
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
- assert old_url.endswith('/'), "Old path must end with /"
467
- assert new_url.endswith('/'), "New path must end with /"
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("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url))
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 move_dir(self, old_url: str, new_url: str, user_id: Optional[int] = None):
498
- assert old_url.endswith('/'), "Old path must end with /"
499
- assert new_url.endswith('/'), "New path must end with /"
500
- if user_id is None:
501
- cursor = await self.cur.execute(
502
- "SELECT * FROM fmeta WHERE url LIKE ? ESCAPE '\\'",
503
- (self.escape_sqlike(old_url) + '%', )
504
- )
505
- res = await cursor.fetchall()
506
- else:
507
- cursor = await self.cur.execute(
508
- "SELECT * FROM fmeta WHERE url LIKE ? ESCAPE '\\' AND owner_id = ?",
509
- (self.escape_sqlike(old_url) + '%', user_id)
510
- )
511
- res = await cursor.fetchall()
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
- new_r = new_url + r[0][len(old_url):]
514
- if await (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))).fetchone():
515
- self.logger.error(f"File {new_r} already exists on move path: {old_url} -> {new_url}")
516
- raise FileDuplicateError(f"File {new_r} already exists")
517
- await self.cur.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_r, r[0]))
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 delete_user_file_records(self, owner_id: int) -> list[FileRecord]:
533
- """ Delete all records with owner_id """
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
- await self.cur.execute("DELETE FROM usize WHERE user_id = ?", (owner_id, ))
537
- res = await self.cur.execute("DELETE FROM fmeta WHERE owner_id = ? RETURNING *", (owner_id, ))
538
- ret = [self.parse_record(r) for r in await res.fetchall()]
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
- if under_owner_id is None:
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
- assert old_url != new_url, "Old and new path must be different"
994
- assert old_url.endswith('/'), "Old path must end with /"
995
- assert new_url.endswith('/'), "New path must end with /"
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
- assert old_url != new_url, "Old and new path must be different"
1017
- assert old_url.endswith('/'), "Old path must end with /"
1018
- assert new_url.endswith('/'), "New path must end with /"
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
- async def __batch_delete_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
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
- 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
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, from_owner_id)
1098
+ records = await fconn.delete_records_by_prefix(url)
1057
1099
  if not records:
1058
1100
  return None
1059
- await self.__batch_delete_file_blobs(fconn, records)
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
- # may contain admin's files, but delete them all
1080
- await fconn.delete_records_by_prefix(user.username + '/')
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 InvalidPathError(f"Invalid path: {path_username} is not a valid username")
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 InvalidPathError(LFSSExceptionBase, ValueError):...
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
@@ -1,7 +1,9 @@
1
1
  from .app_base import ENABLE_WEBDAV
2
2
  from .app_native import *
3
+ from .app_native_user import *
3
4
 
4
5
  # order matters
6
+ app.include_router(router_user)
5
7
  app.include_router(router_api)
6
8
  if ENABLE_WEBDAV:
7
9
  from .app_dav import *
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
- "router_api", "router_fs", "router_dav",
157
- "get_current_user", "registered_user"
157
+ "get_current_user", "registered_user",
158
+ "router_api",
159
+ "router_user",
160
+ "router_fs",
161
+ "router_dav",
158
162
  ]