lfss 0.9.5__py3-none-any.whl → 0.11.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  # Lite File Storage Service (LFSS)
2
- [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
2
+ [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/lfss)](https://pypi.org/project/lfss/)
3
3
 
4
4
  My experiment on a lightweight and high-performance file/object storage service...
5
5
 
docs/Permission.md CHANGED
@@ -22,16 +22,16 @@ A file is owned by the user who created it, may not necessarily be the user unde
22
22
  ## File access with `GET` permission
23
23
 
24
24
  ### File access
25
- For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the owner and the file.
25
+ For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the path-owner and the file.
26
26
 
27
27
  There are four types of permissions: `unset`, `public`, `protected`, `private`.
28
28
  Non-admin users can access files based on:
29
29
 
30
30
  - If the file is `public`, then all users can access it.
31
31
  - If the file is `protected`, then only the logged-in user can access it.
32
- - If the file is `private`, then only the owner can access it.
33
- - If the file is `unset`, then the file's permission is inherited from the owner's permission.
34
- - If both the owner and the file have `unset` permission, then the file is `public`.
32
+ - If the file is `private`, then only the owner/path-owner can access it.
33
+ - If the file is `unset`, then the file's permission is inherited from the path-owner's permission.
34
+ - If both the path-owner and the file have `unset` permission, then the file is `public`.
35
35
 
36
36
  ## File creation with `PUT`/`POST` permission
37
37
  `PUT`/`POST` permission is not allowed for non-peer users.
lfss/api/__init__.py CHANGED
@@ -170,14 +170,15 @@ def download_directory(
170
170
  _counter = 0
171
171
  _counter_lock = Lock()
172
172
  failed_items: list[tuple[str, str]] = []
173
+ file_count = 0
173
174
  def get_file(c, src_url):
174
- nonlocal _counter, failed_items
175
+ nonlocal _counter, failed_items, file_count, verbose
175
176
  with _counter_lock:
176
177
  _counter += 1
177
178
  this_count = _counter
178
179
  dst_path = f"{directory}{os.path.relpath(decode_uri_compnents(src_url), decode_uri_compnents(src_path))}"
179
180
  if verbose:
180
- print(f"[{this_count}] Downloading {src_url} to {dst_path}")
181
+ print(f"[{this_count}/{file_count}] Downloading {src_url} to {dst_path}")
181
182
 
182
183
  if not (res:=download_file(
183
184
  c, src_url, dst_path,
@@ -185,11 +186,13 @@ def download_directory(
185
186
  ))[0]:
186
187
  failed_items.append((src_url, res[1]))
187
188
 
188
- batch_size = 10000
189
+ batch_size = 10_000
189
190
  file_list: list[FileRecord] = []
190
191
  with connector.session(n_concurrent) as c:
191
192
  file_count = c.count_files(src_path, flat=True)
192
193
  for offset in range(0, file_count, batch_size):
194
+ if verbose:
195
+ print(f"Retrieving file list... ({offset}/{file_count})", end='\r')
193
196
  file_list.extend(c.list_files(
194
197
  src_path, offset=offset, limit=batch_size, flat=True
195
198
  ))
lfss/api/connector.py CHANGED
@@ -15,12 +15,13 @@ from lfss.eng.utils import ensure_uri_compnents
15
15
 
16
16
  _default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
17
17
  _default_token = os.environ.get('LFSS_TOKEN', '')
18
+ num_t = float | int
18
19
 
19
20
  class Connector:
20
21
  class Session:
21
22
  def __init__(
22
23
  self, connector: Connector, pool_size: int = 10,
23
- retry: int = 1, backoff_factor: float = 0.5, status_forcelist: list[int] = [503]
24
+ retry: int = 1, backoff_factor: num_t = 0.5, status_forcelist: list[int] = [503]
24
25
  ):
25
26
  self.connector = connector
26
27
  self.pool_size = pool_size
@@ -47,13 +48,21 @@ class Connector:
47
48
  def __exit__(self, exc_type, exc_value, traceback):
48
49
  self.close()
49
50
 
50
- def __init__(self, endpoint=_default_endpoint, token=_default_token):
51
+ def __init__(self, endpoint=_default_endpoint, token=_default_token, timeout: Optional[num_t | tuple[num_t, num_t]]=None, verify: Optional[bool | str] = None):
52
+ """
53
+ - endpoint: the URL of the LFSS server. Default to $LFSS_ENDPOINT or http://localhost:8000.
54
+ - token: the access token. Default to $LFSS_TOKEN.
55
+ - timeout: the timeout for each request, can be either a single value or a tuple of two values (connect, read), refer to requests.Session.request.
56
+ - verify: either a boolean or a string, to control SSL verification. Default to True, refer to requests.Session.request.
57
+ """
51
58
  assert token, "No token provided. Please set LFSS_TOKEN environment variable."
52
59
  self.config = {
53
60
  "endpoint": endpoint,
54
61
  "token": token
55
62
  }
56
63
  self._session: Optional[requests.Session] = None
64
+ self.timeout = timeout
65
+ self.verify = verify
57
66
 
58
67
  def session( self, pool_size: int = 10, **kwargs):
59
68
  """ avoid creating a new session for each request. """
@@ -74,11 +83,11 @@ class Connector:
74
83
  })
75
84
  headers.update(extra_headers)
76
85
  if self._session is not None:
77
- response = self._session.request(method, url, headers=headers, **kwargs)
86
+ response = self._session.request(method, url, headers=headers, timeout=self.timeout, verify=self.verify, **kwargs)
78
87
  response.raise_for_status()
79
88
  else:
80
89
  with requests.Session() as s:
81
- response = s.request(method, url, headers=headers, **kwargs)
90
+ response = s.request(method, url, headers=headers, timeout=self.timeout, verify=self.verify, **kwargs)
82
91
  response.raise_for_status()
83
92
  return response
84
93
  return f
lfss/cli/cli.py CHANGED
@@ -12,7 +12,7 @@ def parse_permission(s: str) -> FileReadPermission:
12
12
  raise ValueError(f"Invalid permission {s}")
13
13
 
14
14
  def parse_arguments():
15
- parser = argparse.ArgumentParser(description="Command line interface, please set LFSS_ENDPOINT and LFSS_TOKEN environment variables.")
15
+ parser = argparse.ArgumentParser(description="Client-side command line interface, set LFSS_ENDPOINT and LFSS_TOKEN environment variables for authentication.")
16
16
 
17
17
  sp = parser.add_subparsers(dest="command", required=True)
18
18
 
lfss/cli/vacuum.py CHANGED
@@ -2,10 +2,11 @@
2
2
  Vacuum the database and external storage to ensure that the storage is consistent and minimal.
3
3
  """
4
4
 
5
- from lfss.eng.config import LARGE_BLOB_DIR
6
- import argparse, time
5
+ from lfss.eng.config import LARGE_BLOB_DIR, THUMB_DB
6
+ import argparse, time, itertools
7
7
  from functools import wraps
8
8
  from asyncio import Semaphore
9
+ import aiosqlite
9
10
  import aiofiles, asyncio
10
11
  import aiofiles.os
11
12
  from contextlib import contextmanager
@@ -32,7 +33,7 @@ def barriered(func):
32
33
  return wrapper
33
34
 
34
35
  @global_entrance()
35
- async def vacuum_main(index: bool = False, blobs: bool = False):
36
+ async def vacuum_main(index: bool = False, blobs: bool = False, thumbs: bool = False, vacuum_all: bool = False):
36
37
 
37
38
  # check if any file in the Large Blob directory is not in the database
38
39
  # the reverse operation is not necessary, because by design, the database should be the source of truth...
@@ -49,23 +50,63 @@ async def vacuum_main(index: bool = False, blobs: bool = False):
49
50
 
50
51
  # create a temporary index to speed up the process...
51
52
  with indicator("Clearing un-referenced files in external storage"):
52
- async with transaction() as c:
53
- await c.execute("CREATE INDEX IF NOT EXISTS fmeta_file_id ON fmeta (file_id)")
54
- for i, f in enumerate(LARGE_BLOB_DIR.iterdir()):
55
- f_id = f.name
56
- await ensure_external_consistency(f_id)
57
- if (i+1) % 1_000 == 0:
58
- print(f"Checked {(i+1)//1000}k files in external storage.", end='\r')
59
- async with transaction() as c:
60
- await c.execute("DROP INDEX IF EXISTS fmeta_file_id")
53
+ try:
54
+ async with transaction() as c:
55
+ await c.execute("CREATE INDEX IF NOT EXISTS fmeta_file_id ON fmeta (file_id)")
56
+ for i, f in enumerate(LARGE_BLOB_DIR.iterdir()):
57
+ f_id = f.name
58
+ await ensure_external_consistency(f_id)
59
+ if (i+1) % 1_000 == 0:
60
+ print(f"Checked {(i+1)//1000}k files in external storage.", end='\r')
61
+ finally:
62
+ async with transaction() as c:
63
+ await c.execute("DROP INDEX IF EXISTS fmeta_file_id")
61
64
 
62
- async with unique_cursor(is_write=True) as c:
63
- if index:
64
- with indicator("VACUUM-index"):
65
+ if index or vacuum_all:
66
+ with indicator("VACUUM-index"):
67
+ async with transaction() as c:
68
+ await c.execute("DELETE FROM dupcount WHERE count = 0")
69
+ async with unique_cursor(is_write=True) as c:
65
70
  await c.execute("VACUUM main")
66
- if blobs:
67
- with indicator("VACUUM-blobs"):
71
+ if blobs or vacuum_all:
72
+ with indicator("VACUUM-blobs"):
73
+ async with unique_cursor(is_write=True) as c:
68
74
  await c.execute("VACUUM blobs")
75
+
76
+ if thumbs or vacuum_all:
77
+ try:
78
+ async with transaction() as c:
79
+ await c.execute("CREATE INDEX IF NOT EXISTS fmeta_file_id ON fmeta (file_id)")
80
+ with indicator("VACUUM-thumbs"):
81
+ if not THUMB_DB.exists():
82
+ raise FileNotFoundError("Thumbnail database not found.")
83
+ async with unique_cursor() as db_c:
84
+ async with aiosqlite.connect(THUMB_DB) as t_conn:
85
+ batch_size = 10_000
86
+ for batch_count in itertools.count(start=0):
87
+ exceeded_rows = list(await (await t_conn.execute(
88
+ "SELECT file_id FROM thumbs LIMIT ? OFFSET ?",
89
+ (batch_size, batch_size * batch_count)
90
+ )).fetchall())
91
+ if not exceeded_rows:
92
+ break
93
+ batch_ids = [row[0] for row in exceeded_rows]
94
+ for f_id in batch_ids:
95
+ cursor = await db_c.execute("SELECT file_id FROM fmeta WHERE file_id = ?", (f_id,))
96
+ if not await cursor.fetchone():
97
+ print(f"Thumbnail {f_id} not found in database, removing from thumb cache.")
98
+ await t_conn.execute("DELETE FROM thumbs WHERE file_id = ?", (f_id,))
99
+ print(f"Checked {batch_count+1} batches of {batch_size} thumbnails.")
100
+
101
+ await t_conn.commit()
102
+ await t_conn.execute("VACUUM")
103
+ except FileNotFoundError as e:
104
+ if "Thumbnail database not found." in str(e):
105
+ print("Thumbnail database not found, skipping.")
106
+
107
+ finally:
108
+ async with transaction() as c:
109
+ await c.execute("DROP INDEX IF EXISTS fmeta_file_id")
69
110
 
70
111
  async def vacuum_requests():
71
112
  with indicator("VACUUM-requests"):
@@ -76,15 +117,17 @@ async def vacuum_requests():
76
117
  def main():
77
118
  global sem
78
119
  parser = argparse.ArgumentParser(description="Balance the storage by ensuring that large file thresholds are met.")
120
+ parser.add_argument("--all", action="store_true", help="Vacuum all")
79
121
  parser.add_argument("-j", "--jobs", type=int, default=2, help="Number of concurrent jobs")
80
122
  parser.add_argument("-m", "--metadata", action="store_true", help="Vacuum metadata")
81
123
  parser.add_argument("-d", "--data", action="store_true", help="Vacuum blobs")
124
+ parser.add_argument("-t", "--thumb", action="store_true", help="Vacuum thumbnails")
82
125
  parser.add_argument("-r", "--requests", action="store_true", help="Vacuum request logs to only keep at most recent 1M rows in 7 days")
83
126
  args = parser.parse_args()
84
127
  sem = Semaphore(args.jobs)
85
- asyncio.run(vacuum_main(index=args.metadata, blobs=args.data))
128
+ asyncio.run(vacuum_main(index=args.metadata, blobs=args.data, thumbs=args.thumb, vacuum_all=args.all))
86
129
 
87
- if args.requests:
130
+ if args.requests or args.all:
88
131
  asyncio.run(vacuum_requests())
89
132
 
90
133
  if __name__ == '__main__':
lfss/eng/config.py CHANGED
@@ -22,5 +22,5 @@ MAX_MEM_FILE_BYTES = 128 * 1024 * 1024 # 128MB
22
22
  CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming (on large files)
23
23
  DEBUG_MODE = os.environ.get('LFSS_DEBUG', '0') == '1'
24
24
 
25
- THUMB_DB = DATA_HOME / 'thumbs.db'
25
+ THUMB_DB = DATA_HOME / 'thumbs.v0-11.db'
26
26
  THUMB_SIZE = (48, 48)
@@ -8,7 +8,7 @@ from functools import wraps
8
8
  from typing import Callable, Awaitable
9
9
 
10
10
  from .log import get_logger
11
- from .error import DatabaseLockedError
11
+ from .error import DatabaseLockedError, DatabaseTransactionError
12
12
  from .config import DATA_HOME
13
13
 
14
14
  async def execute_sql(conn: aiosqlite.Connection | aiosqlite.Cursor, name: str):
@@ -147,6 +147,14 @@ def global_entrance(n_read: int = 1):
147
147
  return wrapper
148
148
  return decorator
149
149
 
150
+ def handle_sqlite_error(e: Exception):
151
+ if 'database is locked' in str(e):
152
+ raise DatabaseLockedError from e
153
+ if 'cannot start a transaction within a transaction' in str(e):
154
+ get_logger('database', global_instance=True).error(f"Unexpected error: {e}")
155
+ raise DatabaseTransactionError from e
156
+ raise e
157
+
150
158
  @asynccontextmanager
151
159
  async def unique_cursor(is_write: bool = False):
152
160
  if not is_write:
@@ -155,9 +163,7 @@ async def unique_cursor(is_write: bool = False):
155
163
  try:
156
164
  yield await connection_obj.conn.cursor()
157
165
  except Exception as e:
158
- if 'database is locked' in str(e):
159
- raise DatabaseLockedError from e
160
- raise e
166
+ handle_sqlite_error(e)
161
167
  finally:
162
168
  await g_pool.release(connection_obj)
163
169
  else:
@@ -166,9 +172,7 @@ async def unique_cursor(is_write: bool = False):
166
172
  try:
167
173
  yield await connection_obj.conn.cursor()
168
174
  except Exception as e:
169
- if 'database is locked' in str(e):
170
- raise DatabaseLockedError from e
171
- raise e
175
+ handle_sqlite_error(e)
172
176
  finally:
173
177
  await g_pool.release(connection_obj)
174
178
 
lfss/eng/database.py CHANGED
@@ -3,6 +3,7 @@ from typing import Optional, Literal, overload
3
3
  from collections.abc import AsyncIterable
4
4
  from contextlib import asynccontextmanager
5
5
  from abc import ABC
6
+ import re
6
7
 
7
8
  import uuid, datetime
8
9
  import urllib.parse
@@ -20,7 +21,7 @@ from .datatype import (
20
21
  )
21
22
  from .config import LARGE_BLOB_DIR, CHUNK_SIZE, LARGE_FILE_BYTES, MAX_MEM_FILE_BYTES
22
23
  from .log import get_logger
23
- from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async, copy_file
24
+ from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async, static_vars
24
25
  from .error import *
25
26
 
26
27
  class DBObjectBase(ABC):
@@ -84,8 +85,9 @@ class UserConn(DBObjectBase):
84
85
  max_storage: int = 1073741824, permission: FileReadPermission = FileReadPermission.UNSET
85
86
  ) -> int:
86
87
  def validate_username(username: str):
88
+ assert not set(username) & {'/', ':'}, "Invalid username"
87
89
  assert not username.startswith('_'), "Error: reserved username"
88
- assert not ('/' in username or ':' in username or len(username) > 255), "Invalid username"
90
+ assert not (len(username) > 255), "Username too long"
89
91
  assert urllib.parse.quote(username) == username, "Invalid username, must be URL safe"
90
92
  validate_username(username)
91
93
  self.logger.debug(f"Creating user {username}")
@@ -249,7 +251,7 @@ class FileConn(DBObjectBase):
249
251
 
250
252
  async def list_path_dirs(
251
253
  self, url: str,
252
- offset: int = 0, limit: int = int(1e5),
254
+ offset: int = 0, limit: int = 10_000,
253
255
  order_by: DirSortKey = '', order_desc: bool = False,
254
256
  skim: bool = True
255
257
  ) -> list[DirectoryRecord]:
@@ -293,7 +295,7 @@ class FileConn(DBObjectBase):
293
295
 
294
296
  async def list_path_files(
295
297
  self, url: str,
296
- offset: int = 0, limit: int = int(1e5),
298
+ offset: int = 0, limit: int = 10_000,
297
299
  order_by: FileSortKey = '', order_desc: bool = False,
298
300
  flat: bool = False,
299
301
  ) -> list[FileRecord]:
@@ -324,7 +326,7 @@ class FileConn(DBObjectBase):
324
326
  - It cannot flatten directories
325
327
  - It cannot list directories with details
326
328
  """
327
- MAX_ITEMS = int(1e4)
329
+ MAX_ITEMS = 10_000
328
330
  dir_count = await self.count_path_dirs(url)
329
331
  file_count = await self.count_path_files(url, flat=False)
330
332
  if dir_count + file_count > MAX_ITEMS:
@@ -417,16 +419,12 @@ class FileConn(DBObjectBase):
417
419
  new_exists = await self.get_file_record(new_url)
418
420
  if new_exists is not None:
419
421
  raise FileExistsError(f"File {new_url} already exists")
420
- new_fid = str(uuid.uuid4())
421
422
  user_id = old.owner_id if user_id is None else user_id
422
423
  await self.cur.execute(
423
424
  "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external, mime_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
424
- (new_url, user_id, new_fid, old.file_size, old.permission, old.external, old.mime_type)
425
+ (new_url, user_id, old.file_id, old.file_size, old.permission, old.external, old.mime_type)
425
426
  )
426
- if not old.external:
427
- await self.set_file_blob(new_fid, await self.get_file_blob(old.file_id))
428
- else:
429
- await copy_file(LARGE_BLOB_DIR / old.file_id, LARGE_BLOB_DIR / new_fid)
427
+ await self.cur.execute("INSERT OR REPLACE INTO dupcount (file_id, count) VALUES (?, COALESCE((SELECT count FROM dupcount WHERE file_id = ?), 0) + 1)", (old.file_id, old.file_id))
430
428
  await self._user_size_inc(user_id, old.file_size)
431
429
  self.logger.info(f"Copied file {old_url} to {new_url}")
432
430
 
@@ -444,16 +442,12 @@ class FileConn(DBObjectBase):
444
442
  new_r = new_url + old_record.url[len(old_url):]
445
443
  if await (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))).fetchone() is not None:
446
444
  raise FileExistsError(f"File {new_r} already exists")
447
- new_fid = str(uuid.uuid4())
448
445
  user_id = old_record.owner_id if user_id is None else user_id
449
446
  await self.cur.execute(
450
447
  "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external, mime_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
451
- (new_r, user_id, new_fid, old_record.file_size, old_record.permission, old_record.external, old_record.mime_type)
448
+ (new_r, user_id, old_record.file_id, old_record.file_size, old_record.permission, old_record.external, old_record.mime_type)
452
449
  )
453
- if not old_record.external:
454
- await self.set_file_blob(new_fid, await self.get_file_blob(old_record.file_id))
455
- else:
456
- await copy_file(LARGE_BLOB_DIR / old_record.file_id, LARGE_BLOB_DIR / new_fid)
450
+ await self.cur.execute("INSERT OR REPLACE INTO dupcount (file_id, count) VALUES (?, COALESCE((SELECT count FROM dupcount WHERE file_id = ?), 0) + 1)", (old_record.file_id, old_record.file_id))
457
451
  await self._user_size_inc(user_id, old_record.file_size)
458
452
  self.logger.info(f"Copied path {old_url} to {new_url}")
459
453
 
@@ -497,6 +491,7 @@ class FileConn(DBObjectBase):
497
491
  return file_record
498
492
 
499
493
  async def delete_user_file_records(self, owner_id: int) -> list[FileRecord]:
494
+ """ Delete all records with owner_id """
500
495
  cursor = await self.cur.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, ))
501
496
  res = await cursor.fetchall()
502
497
  await self.cur.execute("DELETE FROM usize WHERE user_id = ?", (owner_id, ))
@@ -528,7 +523,7 @@ class FileConn(DBObjectBase):
528
523
  return [self.parse_record(r) for r in all_f_rec]
529
524
 
530
525
  async def set_file_blob(self, file_id: str, blob: bytes):
531
- await self.cur.execute("INSERT OR REPLACE INTO blobs.fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
526
+ await self.cur.execute("INSERT INTO blobs.fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
532
527
 
533
528
  @staticmethod
534
529
  async def set_file_blob_external(file_id: str, stream: AsyncIterable[bytes])->int:
@@ -580,16 +575,78 @@ class FileConn(DBObjectBase):
580
575
  if not chunk: break
581
576
  yield chunk
582
577
 
583
- @staticmethod
584
- async def delete_file_blob_external(file_id: str):
578
+ async def unlink_file_blob_external(self, file_id: str):
579
+ # first check if the file has duplication
580
+ cursor = await self.cur.execute("SELECT count FROM dupcount WHERE file_id = ?", (file_id, ))
581
+ res = await cursor.fetchone()
582
+ if res is not None and res[0] > 0:
583
+ await self.cur.execute("UPDATE dupcount SET count = count - 1 WHERE file_id = ?", (file_id, ))
584
+ return
585
+
586
+ # finally delete the file and the duplication count
585
587
  if (LARGE_BLOB_DIR / file_id).exists():
586
588
  await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
589
+ await self.cur.execute("DELETE FROM dupcount WHERE file_id = ?", (file_id, ))
587
590
 
588
- async def delete_file_blob(self, file_id: str):
591
+ async def unlink_file_blob(self, file_id: str):
592
+ # first check if the file has duplication
593
+ cursor = await self.cur.execute("SELECT count FROM dupcount WHERE file_id = ?", (file_id, ))
594
+ res = await cursor.fetchone()
595
+ if res is not None and res[0] > 0:
596
+ await self.cur.execute("UPDATE dupcount SET count = count - 1 WHERE file_id = ?", (file_id, ))
597
+ return
598
+
599
+ # finally delete the file and the duplication count
589
600
  await self.cur.execute("DELETE FROM blobs.fdata WHERE file_id = ?", (file_id, ))
601
+ await self.cur.execute("DELETE FROM dupcount WHERE file_id = ?", (file_id, ))
590
602
 
591
- async def delete_file_blobs(self, file_ids: list[str]):
592
- await self.cur.execute("DELETE FROM blobs.fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
603
+ async def _group_del(self, file_ids_all: list[str]):
604
+ """
605
+ The file_ids_all may contain duplication,
606
+ yield tuples of unique (to_del_ids, to_dec_ids) for each iteration,
607
+ every iteration should unlink one copy of the files, repeat until all re-occurrence in the input list are removed.
608
+ """
609
+ async def check_dup(file_ids: set[str]):
610
+ cursor = await self.cur.execute("SELECT file_id FROM dupcount WHERE file_id IN ({}) AND count > 0".format(','.join(['?'] * len(file_ids))), tuple(file_ids))
611
+ res = await cursor.fetchall()
612
+ to_dec_ids = [r[0] for r in res]
613
+ to_del_ids = list(file_ids - set(to_dec_ids))
614
+ return to_del_ids, to_dec_ids
615
+ # gather duplication from all file_ids
616
+ fid_occurrence = {}
617
+ for file_id in file_ids_all:
618
+ fid_occurrence[file_id] = fid_occurrence.get(file_id, 0) + 1
619
+ while fid_occurrence:
620
+ to_del_ids, to_dec_ids = await check_dup(set(fid_occurrence.keys()))
621
+ for file_id in to_del_ids:
622
+ del fid_occurrence[file_id]
623
+ for file_id in to_dec_ids:
624
+ fid_occurrence[file_id] -= 1
625
+ if fid_occurrence[file_id] == 0:
626
+ del fid_occurrence[file_id]
627
+ yield (to_del_ids, to_dec_ids)
628
+
629
+ async def unlink_file_blobs(self, file_ids: list[str]):
630
+ async for (to_del_ids, to_dec_ids) in self._group_del(file_ids):
631
+ # delete the only copy
632
+ await self.cur.execute("DELETE FROM blobs.fdata WHERE file_id IN ({})".format(','.join(['?'] * len(to_del_ids))), to_del_ids)
633
+ await self.cur.execute("DELETE FROM dupcount WHERE file_id IN ({})".format(','.join(['?'] * len(to_del_ids))), to_del_ids)
634
+ # decrease duplication count
635
+ await self.cur.execute("UPDATE dupcount SET count = count - 1 WHERE file_id IN ({})".format(','.join(['?'] * len(to_dec_ids))), to_dec_ids)
636
+
637
+ async def unlink_file_blobs_external(self, file_ids: list[str]):
638
+ async def del_file(file_id: str):
639
+ if (LARGE_BLOB_DIR / file_id).exists():
640
+ await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
641
+ async for (to_del_ids, to_dec_ids) in self._group_del(file_ids):
642
+ # delete the only copy
643
+ await asyncio.gather(*(
644
+ [del_file(file_id) for file_id in to_del_ids] +
645
+ [self.cur.execute("DELETE FROM dupcount WHERE file_id = ?", (file_id, )) for file_id in to_del_ids]
646
+ ))
647
+ # decrease duplication count
648
+ await self.cur.execute("UPDATE dupcount SET count = count - 1 WHERE file_id IN ({})".format(','.join(['?'] * len(to_dec_ids))), to_dec_ids)
649
+
593
650
 
594
651
  _log_active_queue = []
595
652
  _log_active_lock = asyncio.Lock()
@@ -621,20 +678,35 @@ async def delayed_log_access(url: str):
621
678
  _log_access_queue.append(url)
622
679
  await _log_all_access()
623
680
 
681
+ @static_vars(
682
+ prohibited_regex = re.compile(
683
+ r"^[/_.]", # start with / or _ or .
684
+ ),
685
+ prohibited_part_regex = re.compile(
686
+ "|".join([
687
+ r"^\s*\.+\s*$", # dot path
688
+ "[{}]".format("".join(re.escape(c) for c in ('/', "\\", "'", '"', "*", "%"))), # prohibited characters
689
+ ])
690
+ ),
691
+ )
624
692
  def validate_url(url: str, is_file = True):
625
- prohibited_chars = ['..', ';', "'", '"', '\\', '\0', '\n', '\r', '\t', '\x0b', '\x0c']
626
- ret = not url.startswith('/') and not url.startswith('_') and not url.startswith('.')
627
- ret = ret and not any([c in url for c in prohibited_chars])
693
+ """ Check if a path is valid. The input path is considered url safe """
694
+ if len(url) > 1024:
695
+ raise InvalidPathError(f"URL too long: {url}")
628
696
 
629
- if not ret:
697
+ is_valid = validate_url.prohibited_regex.search(url) is None
698
+ if not is_valid: # early return, no need to check further
630
699
  raise InvalidPathError(f"Invalid URL: {url}")
631
-
632
- if is_file:
633
- ret = ret and not url.endswith('/')
634
- else:
635
- ret = ret and url.endswith('/')
636
700
 
637
- if not ret:
701
+ for part in url.split('/'):
702
+ if validate_url.prohibited_part_regex.search(urllib.parse.unquote(part)):
703
+ is_valid = False
704
+ break
705
+
706
+ if is_file: is_valid = is_valid and not url.endswith('/')
707
+ else: is_valid = is_valid and url.endswith('/')
708
+
709
+ if not is_valid:
638
710
  raise InvalidPathError(f"Invalid URL: {url}")
639
711
 
640
712
  async def get_user(cur: aiosqlite.Cursor, user: int | str) -> Optional[UserRecord]:
@@ -771,9 +843,9 @@ class Database:
771
843
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot delete file {url}")
772
844
  f_id = r.file_id
773
845
  if r.external:
774
- await fconn.delete_file_blob_external(f_id)
846
+ await fconn.unlink_file_blob_external(f_id)
775
847
  else:
776
- await fconn.delete_file_blob(f_id)
848
+ await fconn.unlink_file_blob(f_id)
777
849
  return r
778
850
 
779
851
  async def move_file(self, old_url: str, new_url: str, op_user: Optional[UserRecord] = None):
@@ -872,11 +944,12 @@ class Database:
872
944
 
873
945
  async def del_internal():
874
946
  for i in range(0, len(internal_ids), batch_size):
875
- await fconn.delete_file_blobs([r for r in internal_ids[i:i+batch_size]])
947
+ await fconn.unlink_file_blobs([r for r in internal_ids[i:i+batch_size]])
876
948
  async def del_external():
877
- for i in range(0, len(external_ids)):
878
- await fconn.delete_file_blob_external(external_ids[i])
879
- await asyncio.gather(del_internal(), del_external())
949
+ for i in range(0, len(external_ids), batch_size):
950
+ await fconn.unlink_file_blobs_external([r for r in external_ids[i:i+batch_size]])
951
+ await del_internal()
952
+ await del_external()
880
953
 
881
954
  async def delete_path(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
882
955
  validate_url(url, is_file=False)
@@ -965,6 +1038,11 @@ class Database:
965
1038
  async def zip_path(self, top_url: str, op_user: Optional[UserRecord]) -> io.BytesIO:
966
1039
  if top_url.startswith('/'):
967
1040
  top_url = top_url[1:]
1041
+
1042
+ if op_user:
1043
+ if await check_path_permission(top_url, op_user) < AccessLevel.READ:
1044
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot zip path {top_url}")
1045
+
968
1046
  buffer = io.BytesIO()
969
1047
  with zipfile.ZipFile(buffer, 'w') as zf:
970
1048
  async for (r, blob) in self.iter_path(top_url, None):
@@ -979,39 +1057,50 @@ class Database:
979
1057
  buffer.seek(0)
980
1058
  return buffer
981
1059
 
982
- def check_file_read_permission(user: UserRecord, owner: UserRecord, file: FileRecord) -> tuple[bool, str]:
1060
+ async def _get_path_owner(cur: aiosqlite.Cursor, path: str) -> UserRecord:
1061
+ path_username = path.split('/')[0]
1062
+ uconn = UserConn(cur)
1063
+ path_user = await uconn.get_user(path_username)
1064
+ if path_user is None:
1065
+ raise PathNotFoundError(f"Invalid path: {path_username} is not a valid username")
1066
+ return path_user
1067
+
1068
+ async def check_file_read_permission(user: UserRecord, file: FileRecord, cursor: Optional[aiosqlite.Cursor] = None) -> tuple[bool, str]:
983
1069
  """
984
1070
  This does not consider alias level permission,
985
1071
  use check_path_permission for alias level permission check first:
986
1072
  ```
987
- if await check_path_permission(path, user) < AccessLevel.READ:
988
- read_allowed, reason = check_file_read_permission(user, owner, file)
1073
+ if await check_path_permission(file.url, user) < AccessLevel.READ:
1074
+ read_allowed, reason = check_file_read_permission(user, file)
989
1075
  ```
1076
+ The implementation assumes the user is not admin and is not the owner of the file/path
990
1077
  """
991
- if user.is_admin:
992
- return True, ""
1078
+ @asynccontextmanager
1079
+ async def this_cur():
1080
+ if cursor is None:
1081
+ async with unique_cursor() as _cur:
1082
+ yield _cur
1083
+ else:
1084
+ yield cursor
1085
+
1086
+ f_perm = file.permission
1087
+
1088
+ # if file permission unset, use path owner's permission as fallback
1089
+ if f_perm == FileReadPermission.UNSET:
1090
+ async with this_cur() as cur:
1091
+ path_owner = await _get_path_owner(cur, file.url)
1092
+ f_perm = path_owner.permission
993
1093
 
994
1094
  # check permission of the file
995
- if file.permission == FileReadPermission.PRIVATE:
996
- if user.id != owner.id:
997
- return False, "Permission denied, private file"
998
- elif file.permission == FileReadPermission.PROTECTED:
1095
+ if f_perm == FileReadPermission.PRIVATE:
1096
+ return False, "Permission denied, private file"
1097
+ elif f_perm == FileReadPermission.PROTECTED:
999
1098
  if user.id == 0:
1000
1099
  return False, "Permission denied, protected file"
1001
- elif file.permission == FileReadPermission.PUBLIC:
1100
+ elif f_perm == FileReadPermission.PUBLIC:
1002
1101
  return True, ""
1003
1102
  else:
1004
- assert file.permission == FileReadPermission.UNSET
1005
-
1006
- # use owner's permission as fallback
1007
- if owner.permission == FileReadPermission.PRIVATE:
1008
- if user.id != owner.id:
1009
- return False, "Permission denied, private user file"
1010
- elif owner.permission == FileReadPermission.PROTECTED:
1011
- if user.id == 0:
1012
- return False, "Permission denied, protected user file"
1013
- else:
1014
- assert owner.permission == FileReadPermission.PUBLIC or owner.permission == FileReadPermission.UNSET
1103
+ assert f_perm == FileReadPermission.UNSET
1015
1104
 
1016
1105
  return True, ""
1017
1106
 
@@ -1025,6 +1114,9 @@ async def check_path_permission(path: str, user: UserRecord, cursor: Optional[ai
1025
1114
  if user.id == 0:
1026
1115
  return AccessLevel.GUEST
1027
1116
 
1117
+ if user.is_admin:
1118
+ return AccessLevel.ALL
1119
+
1028
1120
  @asynccontextmanager
1029
1121
  async def this_cur():
1030
1122
  if cursor is None:
@@ -1034,15 +1126,11 @@ async def check_path_permission(path: str, user: UserRecord, cursor: Optional[ai
1034
1126
  yield cursor
1035
1127
 
1036
1128
  # check if path user exists
1037
- path_username = path.split('/')[0]
1038
1129
  async with this_cur() as cur:
1039
- uconn = UserConn(cur)
1040
- path_user = await uconn.get_user(path_username)
1041
- if path_user is None:
1042
- raise PathNotFoundError(f"Invalid path: {path_username} is not a valid username")
1130
+ path_owner = await _get_path_owner(cur, path)
1043
1131
 
1044
- # check if user is admin
1045
- if user.is_admin or user.username == path_username:
1132
+ # check if user is admin or the owner of the path
1133
+ if user.id == path_owner.id:
1046
1134
  return AccessLevel.ALL
1047
1135
 
1048
1136
  # if the path is a file, check if the user is the owner
@@ -1056,4 +1144,4 @@ async def check_path_permission(path: str, user: UserRecord, cursor: Optional[ai
1056
1144
  # check alias level
1057
1145
  async with this_cur() as cur:
1058
1146
  uconn = UserConn(cur)
1059
- return await uconn.query_peer_level(user.id, path_user.id)
1147
+ return await uconn.query_peer_level(user.id, path_owner.id)
lfss/eng/error.py CHANGED
@@ -12,6 +12,8 @@ class InvalidPathError(LFSSExceptionBase, ValueError):...
12
12
 
13
13
  class DatabaseLockedError(LFSSExceptionBase, sqlite3.DatabaseError):...
14
14
 
15
+ class DatabaseTransactionError(LFSSExceptionBase, sqlite3.DatabaseError):...
16
+
15
17
  class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
16
18
 
17
19
  class FileDuplicateError(LFSSExceptionBase, FileExistsError):...
lfss/eng/thumb.py CHANGED
@@ -11,47 +11,42 @@ from contextlib import asynccontextmanager
11
11
  async def _maybe_init_thumb(c: aiosqlite.Cursor):
12
12
  await c.execute('''
13
13
  CREATE TABLE IF NOT EXISTS thumbs (
14
- path TEXT PRIMARY KEY,
15
- ctime TEXT,
14
+ file_id CHAR(32) PRIMARY KEY,
16
15
  thumb BLOB
17
16
  )
18
17
  ''')
19
- await c.execute('CREATE INDEX IF NOT EXISTS thumbs_path_idx ON thumbs (path)')
18
+ await c.execute('CREATE INDEX IF NOT EXISTS thumbs_path_idx ON thumbs (file_id)')
20
19
 
21
- async def _get_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str) -> Optional[bytes]:
20
+ async def _get_cache_thumb(c: aiosqlite.Cursor, file_id: str) -> Optional[bytes]:
22
21
  res = await c.execute('''
23
- SELECT ctime, thumb FROM thumbs WHERE path = ?
24
- ''', (path, ))
22
+ SELECT thumb FROM thumbs WHERE file_id = ?
23
+ ''', (file_id, ))
25
24
  row = await res.fetchone()
26
25
  if row is None:
27
26
  return None
28
- # check if ctime matches, if not delete and return None
29
- if row[0] != ctime:
30
- await _delete_cache_thumb(c, path)
31
- return None
32
- blob: bytes = row[1]
27
+ blob: bytes = row[0]
33
28
  return blob
34
29
 
35
- async def _save_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str, raw_bytes: bytes) -> bytes:
30
+ async def _save_cache_thumb(c: aiosqlite.Cursor, file_id: str, raw_bytes: bytes) -> bytes:
36
31
  try:
37
32
  raw_img = Image.open(BytesIO(raw_bytes))
38
33
  except Exception:
39
- raise InvalidDataError('Invalid image data for thumbnail: ' + path)
34
+ raise InvalidDataError('Invalid image data for thumbnail: ' + file_id)
40
35
  raw_img.thumbnail(THUMB_SIZE)
41
36
  img = raw_img.convert('RGB')
42
37
  bio = BytesIO()
43
38
  img.save(bio, 'JPEG')
44
39
  blob = bio.getvalue()
45
40
  await c.execute('''
46
- INSERT OR REPLACE INTO thumbs (path, ctime, thumb) VALUES (?, ?, ?)
47
- ''', (path, ctime, blob))
41
+ INSERT OR REPLACE INTO thumbs (file_id, thumb) VALUES (?, ?)
42
+ ''', (file_id, blob))
48
43
  await c.execute('COMMIT') # commit immediately
49
44
  return blob
50
45
 
51
- async def _delete_cache_thumb(c: aiosqlite.Cursor, path: str):
46
+ async def _delete_cache_thumb(c: aiosqlite.Cursor, file_id: str):
52
47
  await c.execute('''
53
- DELETE FROM thumbs WHERE path = ?
54
- ''', (path, ))
48
+ DELETE FROM thumbs WHERE file_id = ?
49
+ ''', (file_id, ))
55
50
  await c.execute('COMMIT')
56
51
 
57
52
  @asynccontextmanager
@@ -75,15 +70,13 @@ async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
75
70
  r = await fconn.get_file_record(path)
76
71
 
77
72
  if r is None:
78
- async with cache_cursor() as cur:
79
- await _delete_cache_thumb(cur, path)
80
73
  raise FileNotFoundError(f'File not found: {path}')
81
74
  if not r.mime_type.startswith('image/'):
82
75
  return None
83
76
 
77
+ file_id = r.file_id
84
78
  async with cache_cursor() as cur:
85
- c_time = r.create_time
86
- thumb_blob = await _get_cache_thumb(cur, path, c_time)
79
+ thumb_blob = await _get_cache_thumb(cur, file_id)
87
80
  if thumb_blob is not None:
88
81
  return thumb_blob, "image/jpeg"
89
82
 
@@ -98,5 +91,5 @@ async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
98
91
  data = await fconn.get_file_blob(r.file_id)
99
92
  assert data is not None
100
93
 
101
- thumb_blob = await _save_cache_thumb(cur, path, c_time, data)
94
+ thumb_blob = await _save_cache_thumb(cur, file_id, data)
102
95
  return thumb_blob, "image/jpeg"
lfss/sql/init.sql CHANGED
@@ -1,4 +1,4 @@
1
- CREATE TABLE IF NOT EXISTS user (
1
+ CREATE TABLE IF NOT EXISTS main.user (
2
2
  id INTEGER PRIMARY KEY AUTOINCREMENT,
3
3
  username VARCHAR(256) UNIQUE NOT NULL,
4
4
  credential VARCHAR(256) NOT NULL,
@@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS user (
9
9
  permission INTEGER DEFAULT 0
10
10
  );
11
11
 
12
- CREATE TABLE IF NOT EXISTS fmeta (
12
+ CREATE TABLE IF NOT EXISTS main.fmeta (
13
13
  url VARCHAR(1024) PRIMARY KEY,
14
14
  owner_id INTEGER NOT NULL,
15
15
  file_id CHAR(32) NOT NULL,
@@ -22,12 +22,17 @@ CREATE TABLE IF NOT EXISTS fmeta (
22
22
  FOREIGN KEY(owner_id) REFERENCES user(id)
23
23
  );
24
24
 
25
- CREATE TABLE IF NOT EXISTS usize (
25
+ CREATE TABLE IF NOT EXISTS main.dupcount (
26
+ file_id CHAR(32) PRIMARY KEY,
27
+ count INTEGER DEFAULT 0
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS main.usize (
26
31
  user_id INTEGER PRIMARY KEY,
27
32
  size INTEGER DEFAULT 0
28
33
  );
29
34
 
30
- CREATE TABLE IF NOT EXISTS upeer (
35
+ CREATE TABLE IF NOT EXISTS main.upeer (
31
36
  src_user_id INTEGER NOT NULL,
32
37
  dst_user_id INTEGER NOT NULL,
33
38
  access_level INTEGER DEFAULT 0,
lfss/svc/app.py CHANGED
@@ -6,4 +6,4 @@ app.include_router(router_api)
6
6
  if ENABLE_WEBDAV:
7
7
  from .app_dav import *
8
8
  app.include_router(router_dav)
9
- app.include_router(router_fs)
9
+ app.include_router(router_fs)
lfss/svc/app_base.py CHANGED
@@ -54,6 +54,7 @@ def handle_exception(fn):
54
54
  if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
55
55
  if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
56
56
  if isinstance(e, DatabaseLockedError): raise HTTPException(status_code=503, detail=str(e))
57
+ if isinstance(e, DatabaseTransactionError): raise HTTPException(status_code=503, detail=str(e))
57
58
  if isinstance(e, FileLockedError): raise HTTPException(status_code=423, detail=str(e))
58
59
  logger.error(f"Uncaptured error in {fn.__name__}: {e}")
59
60
  raise
lfss/svc/app_native.py CHANGED
@@ -5,6 +5,7 @@ from fastapi.responses import StreamingResponse
5
5
  from fastapi.exceptions import HTTPException
6
6
 
7
7
  from ..eng.utils import ensure_uri_compnents
8
+ from ..eng.config import MAX_MEM_FILE_BYTES
8
9
  from ..eng.connection_pool import unique_cursor
9
10
  from ..eng.database import check_file_read_permission, check_path_permission, UserConn, FileConn
10
11
  from ..eng.datatype import (
@@ -92,14 +93,29 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
92
93
  dir_record = await FileConn(cur).get_path_record(path)
93
94
 
94
95
  pathname = f"{path.split('/')[-2]}"
95
- return StreamingResponse(
96
- content = await db.zip_path_stream(path, op_user=user),
97
- media_type = "application/zip",
98
- headers = {
99
- f"Content-Disposition": f"attachment; filename=bundle-{pathname}.zip",
100
- "X-Content-Bytes": str(dir_record.size),
101
- }
102
- )
96
+
97
+ if dir_record.size < MAX_MEM_FILE_BYTES:
98
+ logger.debug(f"Bundle {path} in memory")
99
+ dir_bytes = (await db.zip_path(path, op_user=user)).getvalue()
100
+ return Response(
101
+ content = dir_bytes,
102
+ media_type = "application/zip",
103
+ headers = {
104
+ f"Content-Disposition": f"attachment; filename=bundle-{pathname}.zip",
105
+ "Content-Length": str(len(dir_bytes)),
106
+ "X-Content-Bytes": str(dir_record.size),
107
+ }
108
+ )
109
+ else:
110
+ logger.debug(f"Bundle {path} in stream")
111
+ return StreamingResponse(
112
+ content = await db.zip_path_stream(path, op_user=user),
113
+ media_type = "application/zip",
114
+ headers = {
115
+ f"Content-Disposition": f"attachment; filename=bundle-{pathname}.zip",
116
+ "X-Content-Bytes": str(dir_record.size),
117
+ }
118
+ )
103
119
 
104
120
  @router_api.get("/meta")
105
121
  @handle_exception
@@ -112,9 +128,7 @@ async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
112
128
  if is_file:
113
129
  record = await fconn.get_file_record(path, throw=True)
114
130
  if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
115
- uconn = UserConn(cur)
116
- owner = await uconn.get_user_by_id(record.owner_id, throw=True)
117
- is_allowed, reason = check_file_read_permission(user, owner, record)
131
+ is_allowed, reason = await check_file_read_permission(user, record, cursor=cur)
118
132
  if not is_allowed:
119
133
  raise HTTPException(status_code=403, detail=reason)
120
134
  else:
lfss/svc/common_impl.py CHANGED
@@ -112,11 +112,8 @@ async def get_impl(
112
112
  async with unique_cursor() as cur:
113
113
  fconn = FileConn(cur)
114
114
  file_record = await fconn.get_file_record(path, throw=True)
115
- uconn = UserConn(cur)
116
- owner = await uconn.get_user_by_id(file_record.owner_id, throw=True)
117
-
118
115
  if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
119
- allow_access, reason = check_file_read_permission(user, owner, file_record)
116
+ allow_access, reason = await check_file_read_permission(user, file_record, cursor=cur)
120
117
  if not allow_access:
121
118
  raise HTTPException(status_code=403 if user.id != 0 else 401, detail=reason)
122
119
 
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.9.5
3
+ Version: 0.11.0
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
- Author: li_mengxun
7
- Author-email: limengxun45@outlookc.com
6
+ Author: Li, Mengxun
7
+ Author-email: mengxunli@whu.edu.cn
8
8
  Requires-Python: >=3.10
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3.10
@@ -23,7 +23,7 @@ Project-URL: Repository, https://github.com/MenxLi/lfss
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  # Lite File Storage Service (LFSS)
26
- [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
26
+ [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/lfss)](https://pypi.org/project/lfss/)
27
27
 
28
28
  My experiment on a lightweight and high-performance file/object storage service...
29
29
 
@@ -1,9 +1,9 @@
1
- Readme.md,sha256=ST2E12DJVlKTReiJjRBc7KZyAr8KyqlcK2BoTc_Peaw,1829
2
- docs/Changelog.md,sha256=QYej_hmGnv9t8wjFHXBvmrBOvY7aACZ82oa5SVkIyzM,882
1
+ Readme.md,sha256=B-foESzFWoSI5MEd89AWUzKcVRrTwipM28TK8GN0o8c,1920
3
2
  docs/Enviroment_variables.md,sha256=xaL8qBwT8B2Qe11FaOU3xWrRCh1mJ1VyTFCeFbkd0rs,570
4
3
  docs/Known_issues.md,sha256=ZqETcWP8lzTOel9b2mxEgCnADFF8IxOrEtiVO1NoMAk,251
5
- docs/Permission.md,sha256=mvK8gVBBgoIFJqikcaReU_bUo-mTq_ECqJaDDJoQF7Q,3126
4
+ docs/Permission.md,sha256=thUJx7YRoU63Pb-eqo5l5450DrZN3QYZ36GCn8r66no,3152
6
5
  docs/Webdav.md,sha256=-Ja-BTWSY1BEMAyZycvEMNnkNTPZ49gSPzmf3Lbib70,1547
6
+ docs/changelog.md,sha256=QYej_hmGnv9t8wjFHXBvmrBOvY7aACZ82oa5SVkIyzM,882
7
7
  frontend/api.js,sha256=GlQsNoZFEcy7QUUsLbXv7aP-KxRnIxM37FQHTaakGiQ,19387
8
8
  frontend/index.html,sha256=-k0bJ5FRqdl_H-O441D_H9E-iejgRCaL_z5UeYaS2qc,3384
9
9
  frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
@@ -18,34 +18,34 @@ frontend/styles.css,sha256=xcNLqI3KBsY5TLnku8UIP0Jfr7QLajr1_KNlZj9eheM,4935
18
18
  frontend/thumb.css,sha256=rNsx766amYS2DajSQNabhpQ92gdTpNoQKmV69OKvtpI,295
19
19
  frontend/thumb.js,sha256=46ViD2TlTTWy0fx6wjoAs_5CQ4ajYB90vVzM7UO2IHw,6182
20
20
  frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
21
- lfss/api/__init__.py,sha256=8IJqrpWK1doIyVVbntvVic82A57ncwl5b0BRHX4Ri6A,6660
22
- lfss/api/connector.py,sha256=0iopAvqUiUJDjbAtAjr9ynmURnmB-Ejg3INL-877s_E,12192
21
+ lfss/api/__init__.py,sha256=vg9xx7RwfA9ypeqIteGkjDbjMq_kZy2Uti74-XlE7vM,6822
22
+ lfss/api/connector.py,sha256=Duh57M3dOeG_M5UidZ4hMHK7ot1JsUC6RdXgIn6KTC8,12913
23
23
  lfss/cli/__init__.py,sha256=lPwPmqpa7EXQ4zlU7E7LOe6X2kw_xATGdwoHphUEirA,827
24
24
  lfss/cli/balance.py,sha256=fUbKKAUyaDn74f7mmxMfBL4Q4voyBLHu6Lg_g8GfMOQ,4121
25
- lfss/cli/cli.py,sha256=aYjB8d4k6JUd9efxZK-XOj-mlG4JeOr_0lnj2qqCiK0,8066
25
+ lfss/cli/cli.py,sha256=ZgX3M-0gdArDmOi-zo8RLnRy-4GSwJDRGV1scnE4IJs,8090
26
26
  lfss/cli/panel.py,sha256=Xq3I_n-ctveym-Gh9LaUpzHiLlvt3a_nuDiwUS-MGrg,1597
27
27
  lfss/cli/serve.py,sha256=vTo6_BiD7Dn3VLvHsC5RKRBC3lMu45JVr_0SqpgHdj0,1086
28
28
  lfss/cli/user.py,sha256=1mTroQbaKxHjFCPHT67xwd08v-zxH0RZ_OnVc-4MzL0,5364
29
- lfss/cli/vacuum.py,sha256=GOG72d3NYe9bYCNc3y8JecEmM-DrKlGq3JQcisv_xBg,3702
29
+ lfss/cli/vacuum.py,sha256=SciDsIdy7cfRqrXcCKBAFb9FOLyXriZBZnXlCuy6F5I,6232
30
30
  lfss/eng/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
31
  lfss/eng/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
32
- lfss/eng/config.py,sha256=Vni6h52Ce0njVrHZLAWFL8g34YDBdlmGrmRhpxElxQ8,868
33
- lfss/eng/connection_pool.py,sha256=4xOF1kXXGqCWeLX5ZVFALKjdY8N1VVAVSSTRfCzbj94,6141
34
- lfss/eng/database.py,sha256=9KMV-VeD8Dgd6M9RfNfAKwF5gWm763eJpJ2g5zyn_uY,48691
32
+ lfss/eng/config.py,sha256=FcTtPL7bOpg54nVL_gX-VTIjfN1cafy423ezoWGvouY,874
33
+ lfss/eng/connection_pool.py,sha256=1aq7nSgd7hB9YNV4PjD1RDRyl_moDw3ubBtSLyfgGBs,6320
34
+ lfss/eng/database.py,sha256=bzby4R2CbWuRsNQoWtnN-3fBLMjwLoSL-iirp6IsA_4,53247
35
35
  lfss/eng/datatype.py,sha256=27UB7-l9SICy5lAvKjdzpTL_GohZjzstQcr9PtAq7nM,2709
36
- lfss/eng/error.py,sha256=dAlQHXOnQcSkA2vTugJFSxcyDqoFlPucBoFpTZ7GI6w,654
36
+ lfss/eng/error.py,sha256=JGf5NV-f4rL6tNIDSAx5-l9MG8dEj7F2w_MuOjj1d1o,732
37
37
  lfss/eng/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
38
- lfss/eng/thumb.py,sha256=x9jIHHU1tskmp-TavPPcxGpbmEjCp9gbH6ZlsEfqUxY,3383
38
+ lfss/eng/thumb.py,sha256=AFyWEkkpuCKGWOB9bLlaDwPKzQ9JtCSSmHMhX2Gu3CI,3096
39
39
  lfss/eng/utils.py,sha256=WYoXFFi5308UWtFC8VP792gpzrVbHZZHhP3PaFjxIEY,6770
40
- lfss/sql/init.sql,sha256=8LjHx0TBCkBD62xFfssSeHDqKYVQQJkZAg4rSm046f4,1496
40
+ lfss/sql/init.sql,sha256=FBmVzkNjYUnWjEELRFzf7xb50GngmzmeDVffT1Uk8u8,1625
41
41
  lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
42
- lfss/svc/app.py,sha256=ftWCpepBx-gTSG7i-TB-IdinPPstAYYQjCgnTfeMZeI,219
43
- lfss/svc/app_base.py,sha256=PgL5MNU3QTwgIJP8CflDi9YBZ-uo4hVf74ADyWTo9yg,6742
42
+ lfss/svc/app.py,sha256=r1KUO3sPaaJWbkJF0bcVTD7arPKLs2jFlq52Ixicomo,220
43
+ lfss/svc/app_base.py,sha256=bTQbz945xalyB3UZLlqVBvL6JKGNQ8Fm2KpIvvucPZQ,6850
44
44
  lfss/svc/app_dav.py,sha256=D0KSgjtTktPjIhyIKG5eRmBdh5X8HYFYH151E6gzlbc,18245
45
- lfss/svc/app_native.py,sha256=N2cidZ5sIS0p9HOkPqWvpGkqe4bIA7CgGEp4CsvgZ6Q,8347
46
- lfss/svc/common_impl.py,sha256=0fjbqHWgqDhLfBEu6aC0Z5qgNt67C7z0Qroj7aV3Iq4,13830
45
+ lfss/svc/app_native.py,sha256=JbPge-F9irl26tXKAzfA5DfyjCh0Dgttflztqqrvt0A,8890
46
+ lfss/svc/common_impl.py,sha256=5ZRM24zVZpAeipgDtZUVBMFtArkydlAkn17ic_XL7v8,13733
47
47
  lfss/svc/request_log.py,sha256=v8yXEIzPjaksu76Oh5vgdbUEUrw8Kt4etLAXBWSGie8,3207
48
- lfss-0.9.5.dist-info/METADATA,sha256=fToo-wrY0_XJPbk3cte8hb_4DClyPFv3ZTzJB77G-5w,2623
49
- lfss-0.9.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
50
- lfss-0.9.5.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
51
- lfss-0.9.5.dist-info/RECORD,,
48
+ lfss-0.11.0.dist-info/METADATA,sha256=Exr7PdhSmrOqhURUXUiEyP_q8cUSTQ8ZWTgX7tcmp7s,2712
49
+ lfss-0.11.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
50
+ lfss-0.11.0.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
51
+ lfss-0.11.0.dist-info/RECORD,,
File without changes
File without changes