lfss 0.8.1__py3-none-any.whl → 0.8.3__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.
frontend/info.js CHANGED
@@ -12,8 +12,10 @@ const ensureSlashEnd = (path) => {
12
12
  /**
13
13
  * @param {FileRecord} r
14
14
  * @param {UserRecord} u
15
+ * @param {Connector} c
15
16
  */
16
- export function showInfoPanel(r, u){
17
+ export function showInfoPanel(r, u, c){
18
+ const origin = c.config.endpoint;
17
19
  const innerHTML = `
18
20
  <div class="info-container">
19
21
  <div class="info-container-left">
@@ -46,7 +48,7 @@ export function showInfoPanel(r, u){
46
48
  </div>
47
49
  <div class="info-container-right">
48
50
  <div class="info-path-copy">
49
- <input type="text" value="${window.location.origin}/${r.url}" readonly>
51
+ <input type="text" value="${origin}/${r.url}" readonly>
50
52
  <button class="copy-button" id='copy-btn-full-path'>📋</button>
51
53
  </div>
52
54
  <div class="info-path-copy">
@@ -58,7 +60,7 @@ export function showInfoPanel(r, u){
58
60
  `
59
61
  const [win, closeWin] = createFloatingWindow(innerHTML, {title: 'File Info'});
60
62
  document.getElementById('copy-btn-full-path').onclick = () => {
61
- copyToClipboard(window.location.origin + '/' + r.url);
63
+ copyToClipboard(origin + '/' + r.url);
62
64
  showPopup('Path copied to clipboard', {timeout: 2000, level: 'success'});
63
65
  }
64
66
  document.getElementById('copy-btn-rel-path').onclick = () => {
@@ -77,6 +79,7 @@ export function showDirInfoPanel(r, u, c){
77
79
  if (fmtPath.endsWith('/')) {
78
80
  fmtPath = fmtPath.slice(0, -1);
79
81
  }
82
+ const origin = c.config.endpoint;
80
83
  const innerHTML = `
81
84
  <div class="info-container">
82
85
  <div class="info-container-left">
@@ -105,7 +108,7 @@ export function showDirInfoPanel(r, u, c){
105
108
  </div>
106
109
  <div class="info-container-right">
107
110
  <div class="info-path-copy">
108
- <input type="text" value="${window.location.origin}/${ensureSlashEnd(r.url)}" readonly>
111
+ <input type="text" value="${origin}/${ensureSlashEnd(r.url)}" readonly>
109
112
  <button class="copy-button" id='copy-btn-full-path'>📋</button>
110
113
  </div>
111
114
  <div class="info-path-copy">
@@ -117,7 +120,7 @@ export function showDirInfoPanel(r, u, c){
117
120
  `
118
121
  const [win, closeWin] = createFloatingWindow(innerHTML, {title: 'File Info'});
119
122
  document.getElementById('copy-btn-full-path').onclick = () => {
120
- copyToClipboard(window.location.origin + '/' + ensureSlashEnd(r.url));
123
+ copyToClipboard(origin + '/' + ensureSlashEnd(r.url));
121
124
  showPopup('Path copied to clipboard', {timeout: 2000, level: 'success'});
122
125
  }
123
126
  document.getElementById('copy-btn-rel-path').onclick = () => {
frontend/scripts.js CHANGED
@@ -459,7 +459,7 @@ async function refreshFileList(){
459
459
  infoButton.style.cursor = 'pointer';
460
460
  infoButton.textContent = 'Details';
461
461
  infoButton.addEventListener('click', () => {
462
- showInfoPanel(file, userRecord);
462
+ showInfoPanel(file, userRecord, conn);
463
463
  });
464
464
  actContainer.appendChild(infoButton);
465
465
 
frontend/thumb.js CHANGED
@@ -13,6 +13,7 @@ const ICON_ZIP = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><t
13
13
  const ICON_CODE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>file-code-outline</title><path d="M14 2H6C4.89 2 4 2.9 4 4V20C4 21.11 4.89 22 6 22H18C19.11 22 20 21.11 20 20V8L14 2M18 20H6V4H13V9H18V20M9.54 15.65L11.63 17.74L10.35 19L7 15.65L10.35 12.3L11.63 13.56L9.54 15.65M17 15.65L13.65 19L12.38 17.74L14.47 15.65L12.38 13.56L13.65 12.3L17 15.65Z" /></svg>'
14
14
  const ICON_VIDEO = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>television-play</title><path d="M21,3H3C1.89,3 1,3.89 1,5V17A2,2 0 0,0 3,19H8V21H16V19H21A2,2 0 0,0 23,17V5C23,3.89 22.1,3 21,3M21,17H3V5H21M16,11L9,15V7" /></svg>'
15
15
  const ICON_IMAGE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>image-outline</title><path d="M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z" /></svg>'
16
+ const ICON_MUSIC = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>music-box-outline</title><path d="M16,9H13V14.5A2.5,2.5 0 0,1 10.5,17A2.5,2.5 0 0,1 8,14.5A2.5,2.5 0 0,1 10.5,12C11.07,12 11.58,12.19 12,12.5V7H16V9M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3H19M5,5V19H19V5H5Z" /></svg>'
16
17
 
17
18
  function getIconSVGFromMimeType(mimeType){
18
19
  if (mimeType == 'directory'){
@@ -24,6 +25,9 @@ function getIconSVGFromMimeType(mimeType){
24
25
  if (mimeType.startsWith('image/')){
25
26
  return ICON_IMAGE;
26
27
  }
28
+ if (mimeType.startsWith('audio/')){
29
+ return ICON_MUSIC;
30
+ }
27
31
 
28
32
  if (['application/pdf', 'application/x-pdf'].includes(mimeType)){
29
33
  return ICON_PDF;
lfss/api/__init__.py CHANGED
@@ -16,6 +16,11 @@ def upload_file(
16
16
  ) -> tuple[bool, str]:
17
17
  this_try = 0
18
18
  error_msg = ""
19
+ assert not file_path.endswith('/'), "File path must not end with a slash."
20
+ if dst_url.endswith('/'):
21
+ fname = file_path.split('/')[-1]
22
+ dst_url = f"{dst_url}{fname}"
23
+
19
24
  while this_try <= n_retries:
20
25
  try:
21
26
  fsize = os.path.getsize(file_path)
@@ -31,7 +36,9 @@ def upload_file(
31
36
  raise e
32
37
  if verbose:
33
38
  print(f"Error uploading {file_path}: {e}, retrying...")
34
- error_msg = str(e)
39
+ error_msg = str(e)
40
+ if hasattr(e, 'response'):
41
+ error_msg = f"{error_msg}, {e.response.text}" # type: ignore
35
42
  this_try += 1
36
43
  finally:
37
44
  time.sleep(interval)
@@ -95,7 +102,12 @@ def download_file(
95
102
  ) -> tuple[bool, str]:
96
103
  this_try = 0
97
104
  error_msg = ""
105
+ assert not src_url.endswith('/'), "Source URL must not end with a slash."
98
106
  while this_try <= n_retries:
107
+ if os.path.isdir(file_path):
108
+ fname = src_url.split('/')[-1]
109
+ file_path = os.path.join(file_path, fname)
110
+
99
111
  if not overwrite and os.path.exists(file_path):
100
112
  if verbose:
101
113
  print(f"File {file_path} already exists, skipping download.")
@@ -124,7 +136,9 @@ def download_file(
124
136
  raise e
125
137
  if verbose:
126
138
  print(f"Error downloading {src_url}: {e}, retrying...")
127
- error_msg = str(e)
139
+ error_msg = str(e)
140
+ if hasattr(e, 'response'):
141
+ error_msg = f"{error_msg}, {e.response.text}" # type: ignore
128
142
  this_try += 1
129
143
  finally:
130
144
  time.sleep(interval)
lfss/api/connector.py CHANGED
@@ -54,7 +54,7 @@ class Connector:
54
54
 
55
55
  def _fetch_factory(
56
56
  self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
57
- path: str, search_params: dict = {}
57
+ path: str, search_params: dict = {}, extra_headers: dict = {}
58
58
  ):
59
59
  if path.startswith('/'):
60
60
  path = path[1:]
@@ -65,6 +65,7 @@ class Connector:
65
65
  headers.update({
66
66
  'Authorization': f"Bearer {self.config['token']}",
67
67
  })
68
+ headers.update(extra_headers)
68
69
  if self._session is not None:
69
70
  response = self._session.request(method, url, headers=headers, **kwargs)
70
71
  response.raise_for_status()
@@ -168,6 +169,17 @@ class Connector:
168
169
  response = self._get(path)
169
170
  if response is None: return None
170
171
  return response.content
172
+
173
+ def get_partial(self, path: str, range_start: int = -1, range_end: int = -1) -> Optional[bytes]:
174
+ """
175
+ Downloads a partial file from the specified path.
176
+ start and end are the byte offsets, both inclusive.
177
+ """
178
+ response = self._fetch_factory('GET', path, extra_headers={
179
+ 'Range': f"bytes={range_start if range_start >= 0 else ''}-{range_end if range_end >= 0 else ''}"
180
+ })()
181
+ if response is None: return None
182
+ return response.content
171
183
 
172
184
  def get_stream(self, path: str) -> Iterator[bytes]:
173
185
  """Downloads a file from the specified path, will raise PathNotFoundError if path not found."""
lfss/cli/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ from contextlib import contextmanager
2
+ from typing import Iterable, TypeVar, Generator
3
+ import requests, os
4
+
5
+ @contextmanager
6
+ def catch_request_error():
7
+ try:
8
+ yield
9
+ except requests.RequestException as e:
10
+ print(f"\033[31m[Request error]: {e}\033[0m")
11
+ if e.response is not None:
12
+ print(f"\033[91m[Error message]: {e.response.text}\033[0m")
13
+
14
+ T = TypeVar('T')
15
+ def line_sep(iter: Iterable[T], enable=True, start=True, end=True, color="\033[90m") -> Generator[T, None, None]:
16
+ screen_width = os.get_terminal_size().columns
17
+ def print_ln():
18
+ print(color + "-" * screen_width + "\033[0m")
19
+
20
+ if start and enable:
21
+ print_ln()
22
+ for i, line in enumerate(iter):
23
+ if enable and i > 0:
24
+ print_ln()
25
+ yield line
26
+ if end and enable:
27
+ print_ln()
lfss/cli/cli.py CHANGED
@@ -1,7 +1,9 @@
1
1
  from lfss.api import Connector, upload_directory, upload_file, download_file, download_directory
2
2
  from pathlib import Path
3
- import argparse
4
- from lfss.src.datatype import FileReadPermission
3
+ import argparse, typing
4
+ from lfss.src.datatype import FileReadPermission, FileSortKey, DirSortKey
5
+ from lfss.src.utils import decode_uri_compnents
6
+ from . import catch_request_error, line_sep
5
7
 
6
8
  def parse_permission(s: str) -> FileReadPermission:
7
9
  if s.lower() == "public":
@@ -39,6 +41,29 @@ def parse_arguments():
39
41
  sp_download.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
40
42
  sp_download.add_argument("--retries", type=int, default=0, help="Number of retries")
41
43
 
44
+ # query
45
+ sp_query = sp.add_parser("query", help="Query files or directories metadata from the server")
46
+ sp_query.add_argument("path", help="Path to query", nargs="*", type=str)
47
+
48
+ # list directories
49
+ sp_list_d = sp.add_parser("list-dirs", help="List directories of a given path")
50
+ sp_list_d.add_argument("path", help="Path to list", type=str)
51
+ sp_list_d.add_argument("--offset", type=int, default=0, help="Offset of the list")
52
+ sp_list_d.add_argument("--limit", type=int, default=100, help="Limit of the list")
53
+ sp_list_d.add_argument("-l", "--long", action="store_true", help="Detailed list, including all metadata")
54
+ sp_list_d.add_argument("--order", "--order-by", type=str, help="Order of the list", default="", choices=typing.get_args(DirSortKey))
55
+ sp_list_d.add_argument("--reverse", "--order-desc", action="store_true", help="Reverse the list order")
56
+
57
+ # list files
58
+ sp_list_f = sp.add_parser("list-files", help="List files of a given path")
59
+ sp_list_f.add_argument("path", help="Path to list", type=str)
60
+ sp_list_f.add_argument("--offset", type=int, default=0, help="Offset of the list")
61
+ sp_list_f.add_argument("--limit", type=int, default=100, help="Limit of the list")
62
+ sp_list_f.add_argument("-r", "--recursive", "--flat", action="store_true", help="List files recursively")
63
+ sp_list_f.add_argument("-l", "--long", action="store_true", help="Detailed list, including all metadata")
64
+ sp_list_f.add_argument("--order", "--order-by", type=str, help="Order of the list", default="", choices=typing.get_args(FileSortKey))
65
+ sp_list_f.add_argument("--reverse", "--order-desc", action="store_true", help="Reverse the list order")
66
+
42
67
  return parser.parse_args()
43
68
 
44
69
  def main():
@@ -57,11 +82,11 @@ def main():
57
82
  permission=args.permission
58
83
  )
59
84
  if failed_upload:
60
- print("Failed to upload:")
85
+ print("\033[91mFailed to upload:\033[0m")
61
86
  for path in failed_upload:
62
87
  print(f" {path}")
63
88
  else:
64
- success = upload_file(
89
+ success, msg = upload_file(
65
90
  connector,
66
91
  file_path = args.src,
67
92
  dst_url = args.dst,
@@ -72,7 +97,7 @@ def main():
72
97
  permission=args.permission
73
98
  )
74
99
  if not success:
75
- print("Failed to upload.")
100
+ print("\033[91mFailed to upload: \033[0m", msg)
76
101
 
77
102
  elif args.command == "download":
78
103
  is_dir = args.src.endswith("/")
@@ -86,11 +111,11 @@ def main():
86
111
  overwrite=args.overwrite
87
112
  )
88
113
  if failed_download:
89
- print("Failed to download:")
114
+ print("\033[91mFailed to download:\033[0m")
90
115
  for path in failed_download:
91
116
  print(f" {path}")
92
117
  else:
93
- success = download_file(
118
+ success, msg = download_file(
94
119
  connector,
95
120
  src_url = args.src,
96
121
  file_path = args.dst,
@@ -100,7 +125,51 @@ def main():
100
125
  overwrite=args.overwrite
101
126
  )
102
127
  if not success:
103
- print("Failed to download.")
128
+ print("\033[91mFailed to download: \033[0m", msg)
129
+
130
+ elif args.command == "query":
131
+ for path in args.path:
132
+ with catch_request_error():
133
+ res = connector.get_metadata(path)
134
+ if res is None:
135
+ print(f"\033[31mNot found\033[0m ({path})")
136
+ else:
137
+ print(res)
138
+
139
+ elif args.command == "list-files":
140
+ with catch_request_error():
141
+ res = connector.list_files(
142
+ args.path,
143
+ offset=args.offset,
144
+ limit=args.limit,
145
+ flat=args.recursive,
146
+ order_by=args.order,
147
+ order_desc=args.reverse,
148
+ )
149
+ for i, f in enumerate(line_sep(res)):
150
+ f.url = decode_uri_compnents(f.url)
151
+ print(f"[{i+1}] {f if args.long else f.url}")
152
+
153
+ if len(res) == args.limit:
154
+ print(f"\033[33m[Warning] List limit reached, use --offset and --limit to list more files.")
155
+
156
+ elif args.command == "list-dirs":
157
+ with catch_request_error():
158
+ res = connector.list_dirs(
159
+ args.path,
160
+ offset=args.offset,
161
+ limit=args.limit,
162
+ skim=not args.long,
163
+ order_by=args.order,
164
+ order_desc=args.reverse,
165
+ )
166
+ for i, d in enumerate(line_sep(res)):
167
+ d.url = decode_uri_compnents(d.url)
168
+ print(f"[{i+1}] {d if args.long else d.url}")
169
+
170
+ if len(res) == args.limit:
171
+ print(f"\033[33m[Warning] List limit reached, use --offset and --limit to list more directories.")
172
+
104
173
  else:
105
174
  raise NotImplementedError(f"Command {args.command} not implemented.")
106
175
 
lfss/cli/user.py CHANGED
@@ -1,8 +1,8 @@
1
- import argparse, asyncio
1
+ import argparse, asyncio, os
2
2
  from contextlib import asynccontextmanager
3
3
  from .cli import parse_permission, FileReadPermission
4
- from ..src.utils import parse_storage_size
5
- from ..src.database import Database, FileReadPermission, transaction, UserConn
4
+ from ..src.utils import parse_storage_size, fmt_storage_size
5
+ from ..src.database import Database, FileReadPermission, transaction, UserConn, unique_cursor, FileConn
6
6
  from ..src.connection_pool import global_entrance
7
7
 
8
8
  @global_entrance(1)
@@ -33,6 +33,7 @@ async def _main():
33
33
  sp_set.add_argument('--max-storage', type=parse_storage_size, default=None)
34
34
 
35
35
  sp_list = sp.add_parser('list')
36
+ sp_list.add_argument("username", nargs='*', type=str, default=None)
36
37
  sp_list.add_argument("-l", "--long", action="store_true")
37
38
 
38
39
  args = parser.parse_args()
@@ -73,10 +74,18 @@ async def _main():
73
74
 
74
75
  if args.subparser_name == 'list':
75
76
  async with get_uconn() as uconn:
77
+ term_width = os.get_terminal_size().columns
76
78
  async for user in uconn.all():
79
+ if args.username and not user.username in args.username:
80
+ continue
81
+ print("\033[90m-\033[0m" * term_width)
77
82
  print(user)
78
83
  if args.long:
79
- print(' ', user.credential)
84
+ async with unique_cursor() as c:
85
+ fconn = FileConn(c)
86
+ user_size_used = await fconn.user_size(user.id)
87
+ print('- Credential: ', user.credential)
88
+ print(f'- Storage: {fmt_storage_size(user_size_used)} / {fmt_storage_size(user.max_storage)}')
80
89
 
81
90
  def main():
82
91
  asyncio.run(_main())
lfss/src/database.py CHANGED
@@ -21,6 +21,12 @@ from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debou
21
21
  from .error import *
22
22
 
23
23
  class DBObjectBase(ABC):
24
+ """
25
+ NOTE:
26
+ The object of this class should hold a cursor to the database.
27
+ The methods calling the cursor should not be called concurrently.
28
+ """
29
+
24
30
  logger = get_logger('database', global_instance=True)
25
31
  _cur: aiosqlite.Cursor
26
32
 
@@ -206,7 +212,7 @@ class FileConn(DBObjectBase):
206
212
  return DirectoryRecord(dir_url)
207
213
  else:
208
214
  return await self.get_path_record(dir_url)
209
- dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
215
+ dirs = [await get_dir(url + d) for d in dirs_str]
210
216
  return dirs
211
217
 
212
218
  async def count_path_files(self, url: str, flat: bool = False):
@@ -308,18 +314,14 @@ class FileConn(DBObjectBase):
308
314
  return res[0] or 0
309
315
 
310
316
  async def update_file_record(
311
- self, url, owner_id: Optional[int] = None, permission: Optional[FileReadPermission] = None
317
+ self, url,
318
+ permission: Optional[FileReadPermission] = None,
319
+ mime_type: Optional[str] = None
312
320
  ):
313
- old = await self.get_file_record(url)
314
- assert old is not None, f"File {url} not found"
315
- if owner_id is None:
316
- owner_id = old.owner_id
317
- if permission is None:
318
- permission = old.permission
319
- await self.cur.execute(
320
- "UPDATE fmeta SET owner_id = ?, permission = ? WHERE url = ?",
321
- (owner_id, int(permission), url)
322
- )
321
+ if permission is not None:
322
+ await self.cur.execute("UPDATE fmeta SET permission = ? WHERE url = ?", (int(permission), url))
323
+ if mime_type is not None:
324
+ await self.cur.execute("UPDATE fmeta SET mime_type = ? WHERE url = ?", (mime_type, url))
323
325
  self.logger.info(f"Updated file {url}")
324
326
 
325
327
  async def set_file_record(
@@ -392,7 +394,7 @@ class FileConn(DBObjectBase):
392
394
  self.logger.info(f"Deleted {len(ret)} file records for user {owner_id}") # type: ignore
393
395
  return ret
394
396
 
395
- async def delete_path_records(self, path: str, under_user_id: Optional[int] = None) -> list[FileRecord]:
397
+ async def delete_path_records(self, path: str, under_owner_id: Optional[int] = None) -> list[FileRecord]:
396
398
  """Delete all records with url starting with path"""
397
399
  # update user size
398
400
  cursor = await self.cur.execute("SELECT DISTINCT owner_id FROM fmeta WHERE url LIKE ?", (path + '%', ))
@@ -406,10 +408,10 @@ class FileConn(DBObjectBase):
406
408
  # if any new records are created here, the size update may be inconsistent
407
409
  # but it's not a big deal... we should have only one writer
408
410
 
409
- if under_user_id is None:
411
+ if under_owner_id is None:
410
412
  res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? RETURNING *", (path + '%', ))
411
413
  else:
412
- res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? AND owner_id = ? RETURNING *", (path + '%', under_user_id))
414
+ res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? AND owner_id = ? RETURNING *", (path + '%', under_owner_id))
413
415
  all_f_rec = await res.fetchall()
414
416
  self.logger.info(f"Deleted {len(all_f_rec)} file(s) for path {path}") # type: ignore
415
417
  return [self.parse_record(r) for r in all_f_rec]
@@ -431,20 +433,41 @@ class FileConn(DBObjectBase):
431
433
  raise
432
434
  return size_sum
433
435
 
434
- async def get_file_blob(self, file_id: str) -> Optional[bytes]:
436
+ async def get_file_blob(self, file_id: str, start_byte = -1, end_byte = -1) -> bytes:
435
437
  cursor = await self.cur.execute("SELECT data FROM blobs.fdata WHERE file_id = ?", (file_id, ))
436
438
  res = await cursor.fetchone()
437
439
  if res is None:
438
- return None
439
- return res[0]
440
+ raise FileNotFoundError(f"File {file_id} not found")
441
+ blob = res[0]
442
+ match (start_byte, end_byte):
443
+ case (-1, -1):
444
+ return blob
445
+ case (s, -1):
446
+ return blob[s:]
447
+ case (-1, e):
448
+ return blob[:e]
449
+ case (s, e):
450
+ return blob[s:e]
440
451
 
441
- async def get_file_blob_external(self, file_id: str) -> AsyncIterable[bytes]:
452
+ @staticmethod
453
+ async def get_file_blob_external(file_id: str, start_byte = -1, end_byte = -1) -> AsyncIterable[bytes]:
442
454
  assert (LARGE_BLOB_DIR / file_id).exists(), f"File {file_id} not found"
443
455
  async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'rb') as f:
444
- while True:
445
- chunk = await f.read(CHUNK_SIZE)
446
- if not chunk: break
447
- yield chunk
456
+ if start_byte >= 0:
457
+ await f.seek(start_byte)
458
+ if end_byte >= 0:
459
+ while True:
460
+ head_ptr = await f.tell()
461
+ if head_ptr >= end_byte:
462
+ break
463
+ chunk = await f.read(min(CHUNK_SIZE, end_byte - head_ptr))
464
+ if not chunk: break
465
+ yield chunk
466
+ else:
467
+ while True:
468
+ chunk = await f.read(CHUNK_SIZE)
469
+ if not chunk: break
470
+ yield chunk
448
471
 
449
472
  @staticmethod
450
473
  async def delete_file_blob_external(file_id: str):
@@ -521,15 +544,16 @@ class Database:
521
544
  await execute_sql(conn, 'init.sql')
522
545
  return self
523
546
 
524
- async def update_file_record(self, user: UserRecord, url: str, permission: FileReadPermission):
547
+ async def update_file_record(self, url: str, permission: FileReadPermission, op_user: Optional[UserRecord] = None):
525
548
  validate_url(url)
526
549
  async with transaction() as conn:
527
550
  fconn = FileConn(conn)
528
551
  r = await fconn.get_file_record(url)
529
552
  if r is None:
530
553
  raise PathNotFoundError(f"File {url} not found")
531
- if r.owner_id != user.id and not user.is_admin:
532
- raise PermissionDeniedError(f"Permission denied: {user.username} cannot update file {url}")
554
+ if op_user is not None:
555
+ if r.owner_id != op_user.id and not op_user.is_admin:
556
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot update file {url}")
533
557
  await fconn.update_file_record(url, permission=permission)
534
558
 
535
559
  async def save_file(
@@ -561,8 +585,7 @@ class Database:
561
585
 
562
586
  # check mime type
563
587
  if mime_type is None:
564
- fname = url.split('/')[-1]
565
- mime_type, _ = mimetypes.guess_type(fname)
588
+ mime_type, _ = mimetypes.guess_type(url)
566
589
  if mime_type is None:
567
590
  await f.seek(0)
568
591
  mime_type = mimesniff.what(await f.read(1024))
@@ -591,45 +614,25 @@ class Database:
591
614
  await FileConn(w_cur).set_file_record(
592
615
  url, owner_id=user.id, file_id=f_id, file_size=file_size,
593
616
  permission=permission, external=True, mime_type=mime_type)
594
-
595
- await delayed_log_activity(user.username)
596
617
  return file_size
597
618
 
598
- async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
619
+ async def read_file(self, url: str, start_byte = -1, end_byte = -1) -> AsyncIterable[bytes]:
620
+ # end byte is exclusive: [start_byte, end_byte)
599
621
  validate_url(url)
600
- async with unique_cursor() as cur:
601
- fconn = FileConn(cur)
602
- r = await fconn.get_file_record(url)
603
- if r is None:
604
- raise FileNotFoundError(f"File {url} not found")
605
- if not r.external:
606
- raise ValueError(f"File {url} is not stored externally, should use read_file instead")
607
- ret = fconn.get_file_blob_external(r.file_id)
608
-
609
- await delayed_log_access(url)
610
- return ret
611
-
612
-
613
- async def read_file(self, url: str) -> bytes:
614
- validate_url(url)
615
-
616
622
  async with unique_cursor() as cur:
617
623
  fconn = FileConn(cur)
618
624
  r = await fconn.get_file_record(url)
619
625
  if r is None:
620
626
  raise FileNotFoundError(f"File {url} not found")
621
627
  if r.external:
622
- raise ValueError(f"File {url} is stored externally, should use read_file_stream instead")
623
-
624
- f_id = r.file_id
625
- blob = await fconn.get_file_blob(f_id)
626
- if blob is None:
627
- raise FileNotFoundError(f"File {url} data not found")
628
-
629
- await delayed_log_access(url)
630
- return blob
628
+ ret = fconn.get_file_blob_external(r.file_id, start_byte=start_byte, end_byte=end_byte)
629
+ else:
630
+ async def blob_stream():
631
+ yield await fconn.get_file_blob(r.file_id, start_byte=start_byte, end_byte=end_byte)
632
+ ret = blob_stream()
633
+ return ret
631
634
 
632
- async def delete_file(self, url: str, assure_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
635
+ async def delete_file(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
633
636
  validate_url(url)
634
637
 
635
638
  async with transaction() as cur:
@@ -637,10 +640,10 @@ class Database:
637
640
  r = await fconn.delete_file_record(url)
638
641
  if r is None:
639
642
  return None
640
- if assure_user is not None:
641
- if r.owner_id != assure_user.id:
643
+ if op_user is not None:
644
+ if r.owner_id != op_user.id and not op_user.is_admin:
642
645
  # will rollback
643
- raise PermissionDeniedError(f"Permission denied: {assure_user.username} cannot delete file {url}")
646
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot delete file {url}")
644
647
  f_id = r.file_id
645
648
  if r.external:
646
649
  await fconn.delete_file_blob_external(f_id)
@@ -648,7 +651,7 @@ class Database:
648
651
  await fconn.delete_file_blob(f_id)
649
652
  return r
650
653
 
651
- async def move_file(self, old_url: str, new_url: str, ensure_user: Optional[UserRecord] = None):
654
+ async def move_file(self, old_url: str, new_url: str, op_user: Optional[UserRecord] = None):
652
655
  validate_url(old_url)
653
656
  validate_url(new_url)
654
657
 
@@ -657,12 +660,16 @@ class Database:
657
660
  r = await fconn.get_file_record(old_url)
658
661
  if r is None:
659
662
  raise FileNotFoundError(f"File {old_url} not found")
660
- if ensure_user is not None:
661
- if r.owner_id != ensure_user.id:
662
- raise PermissionDeniedError(f"Permission denied: {ensure_user.username} cannot move file {old_url}")
663
+ if op_user is not None:
664
+ if r.owner_id != op_user.id:
665
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
663
666
  await fconn.move_file(old_url, new_url)
667
+
668
+ new_mime, _ = mimetypes.guess_type(new_url)
669
+ if not new_mime is None:
670
+ await fconn.update_file_record(new_url, mime_type=new_mime)
664
671
 
665
- async def move_path(self, user: UserRecord, old_url: str, new_url: str):
672
+ async def move_path(self, old_url: str, new_url: str, op_user: UserRecord):
666
673
  validate_url(old_url, is_file=False)
667
674
  validate_url(new_url, is_file=False)
668
675
 
@@ -676,20 +683,20 @@ class Database:
676
683
 
677
684
  async with transaction() as cur:
678
685
  first_component = new_url.split('/')[0]
679
- if not (first_component == user.username or user.is_admin):
680
- raise PermissionDeniedError(f"Permission denied: path must start with {user.username}")
681
- elif user.is_admin:
686
+ if not (first_component == op_user.username or op_user.is_admin):
687
+ raise PermissionDeniedError(f"Permission denied: path must start with {op_user.username}")
688
+ elif op_user.is_admin:
682
689
  uconn = UserConn(cur)
683
690
  _is_user = await uconn.get_user(first_component)
684
691
  if not _is_user:
685
692
  raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
686
693
 
687
694
  # check if old path is under user's directory (non-admin)
688
- if not old_url.startswith(user.username + '/') and not user.is_admin:
689
- raise PermissionDeniedError(f"Permission denied: {user.username} cannot move path {old_url}")
695
+ if not old_url.startswith(op_user.username + '/') and not op_user.is_admin:
696
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move path {old_url}")
690
697
 
691
698
  fconn = FileConn(cur)
692
- await fconn.move_path(old_url, new_url, 'overwrite', user.id)
699
+ await fconn.move_path(old_url, new_url, 'overwrite', op_user.id)
693
700
 
694
701
  async def __batch_delete_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
695
702
  # https://github.com/langchain-ai/langchain/issues/10321
@@ -709,13 +716,13 @@ class Database:
709
716
  await fconn.delete_file_blob_external(external_ids[i])
710
717
  await asyncio.gather(del_internal(), del_external())
711
718
 
712
- async def delete_path(self, url: str, under_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
719
+ async def delete_path(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
713
720
  validate_url(url, is_file=False)
714
- user_id = under_user.id if under_user is not None else None
721
+ from_owner_id = op_user.id if op_user is not None and not op_user.is_admin else None
715
722
 
716
723
  async with transaction() as cur:
717
724
  fconn = FileConn(cur)
718
- records = await fconn.delete_path_records(url, user_id)
725
+ records = await fconn.delete_path_records(url, from_owner_id)
719
726
  if not records:
720
727
  return None
721
728
  await self.__batch_delete_file_blobs(fconn, records)
@@ -759,9 +766,6 @@ class Database:
759
766
  blob = fconn.get_file_blob_external(f_id)
760
767
  else:
761
768
  blob = await fconn.get_file_blob(f_id)
762
- if blob is None:
763
- self.logger.warning(f"Blob not found for {url}")
764
- continue
765
769
  yield r, blob
766
770
 
767
771
  @concurrent_wrap()
lfss/src/datatype.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from enum import IntEnum
2
- from typing import Literal
3
- import dataclasses
2
+ import dataclasses, typing
4
3
 
5
4
  class FileReadPermission(IntEnum):
6
5
  UNSET = 0 # not set
@@ -55,7 +54,7 @@ class PathContents:
55
54
  dirs: list[DirectoryRecord] = dataclasses.field(default_factory=list)
56
55
  files: list[FileRecord] = dataclasses.field(default_factory=list)
57
56
 
58
- FileSortKey = Literal['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
59
- isValidFileSortKey = lambda x: x in ['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
60
- DirSortKey = Literal['', 'dirname']
61
- isValidDirSortKey = lambda x: x in ['', 'dirname']
57
+ FileSortKey = typing.Literal['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
58
+ isValidFileSortKey = lambda x: x in typing.get_args(FileSortKey)
59
+ DirSortKey = typing.Literal['', 'dirname']
60
+ isValidDirSortKey = lambda x: x in typing.get_args(DirSortKey)
lfss/src/server.py CHANGED
@@ -6,7 +6,6 @@ from fastapi.responses import StreamingResponse
6
6
  from fastapi.exceptions import HTTPException
7
7
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
8
8
  from fastapi.middleware.cors import CORSMiddleware
9
- import mimesniff
10
9
 
11
10
  import asyncio, json, time
12
11
  from contextlib import asynccontextmanager
@@ -14,10 +13,11 @@ from contextlib import asynccontextmanager
14
13
  from .error import *
15
14
  from .log import get_logger
16
15
  from .stat import RequestDB
17
- from .config import MAX_BUNDLE_BYTES, MAX_MEM_FILE_BYTES, LARGE_FILE_BYTES, CHUNK_SIZE
16
+ from .config import MAX_BUNDLE_BYTES, CHUNK_SIZE
18
17
  from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
19
18
  from .connection_pool import global_connection_init, global_connection_close, unique_cursor
20
- from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn, delayed_log_activity, get_user
19
+ from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn
20
+ from .database import delayed_log_activity, delayed_log_access
21
21
  from .datatype import (
22
22
  FileReadPermission, FileRecord, UserRecord, PathContents,
23
23
  FileSortKey, DirSortKey
@@ -80,6 +80,10 @@ async def get_current_user(
80
80
 
81
81
  if not user:
82
82
  raise HTTPException(status_code=401, detail="Invalid token")
83
+
84
+ if not user.id == 0:
85
+ await delayed_log_activity(user.username)
86
+
83
87
  return user
84
88
 
85
89
  async def registered_user(user: UserRecord = Depends(get_current_user)):
@@ -137,7 +141,8 @@ router_fs = APIRouter(prefix="")
137
141
  @skip_request_log
138
142
  async def emit_thumbnail(
139
143
  path: str, download: bool,
140
- create_time: Optional[str] = None
144
+ create_time: Optional[str] = None,
145
+ is_head = False
141
146
  ):
142
147
  if path.endswith("/"):
143
148
  fname = path.split("/")[-2]
@@ -153,42 +158,69 @@ async def emit_thumbnail(
153
158
  }
154
159
  if create_time is not None:
155
160
  headers["Last-Modified"] = format_last_modified(create_time)
161
+ if is_head: return Response(status_code=200, headers=headers)
156
162
  return Response(
157
163
  content=thumb_blob, media_type=mime_type, headers=headers
158
164
  )
159
165
  async def emit_file(
160
166
  file_record: FileRecord,
161
167
  media_type: Optional[str] = None,
162
- disposition = "attachment"
168
+ disposition = "attachment",
169
+ is_head = False,
170
+ range_start = -1,
171
+ range_end = -1
163
172
  ):
173
+ if range_start < 0: assert range_start == -1
174
+ if range_end < 0: assert range_end == -1
175
+
164
176
  if media_type is None:
165
177
  media_type = file_record.mime_type
166
178
  path = file_record.url
167
179
  fname = path.split("/")[-1]
168
- if not file_record.external:
169
- fblob = await db.read_file(path)
170
- return Response(
171
- content=fblob, media_type=media_type, headers={
172
- "Content-Disposition": f"{disposition}; filename={fname}",
173
- "Content-Length": str(len(fblob)),
174
- "Last-Modified": format_last_modified(file_record.create_time)
175
- }
176
- )
180
+
181
+ if range_start == -1:
182
+ arng_s = 0 # actual range start
177
183
  else:
178
- return StreamingResponse(
179
- await db.read_file_stream(path), media_type=media_type, headers={
180
- "Content-Disposition": f"{disposition}; filename={fname}",
181
- "Content-Length": str(file_record.file_size),
182
- "Last-Modified": format_last_modified(file_record.create_time)
183
- }
184
- )
184
+ arng_s = range_start
185
+ if range_end == -1:
186
+ arng_e = file_record.file_size - 1
187
+ else:
188
+ arng_e = range_end
189
+
190
+ if arng_s >= file_record.file_size or arng_e >= file_record.file_size:
191
+ raise HTTPException(status_code=416, detail="Range not satisfiable")
192
+ if arng_s > arng_e:
193
+ raise HTTPException(status_code=416, detail="Invalid range")
185
194
 
186
- @router_fs.get("/{path:path}")
187
- @handle_exception
188
- async def get_file(
195
+ headers = {
196
+ "Content-Disposition": f"{disposition}; filename={fname}",
197
+ "Content-Length": str(arng_e - arng_s + 1),
198
+ "Content-Range": f"bytes {arng_s}-{arng_e}/{file_record.file_size}",
199
+ "Last-Modified": format_last_modified(file_record.create_time),
200
+ "Accept-Ranges": "bytes",
201
+ }
202
+
203
+ if is_head: return Response(status_code=200 if (range_start == -1 and range_end == -1) else 206, headers=headers)
204
+
205
+ await delayed_log_access(path)
206
+ return StreamingResponse(
207
+ await db.read_file(
208
+ path,
209
+ start_byte=arng_s if range_start != -1 else -1,
210
+ end_byte=arng_e + 1 if range_end != -1 else -1
211
+ ),
212
+ media_type=media_type,
213
+ headers=headers,
214
+ status_code=206 if range_start != -1 or range_end != -1 else 200
215
+ )
216
+
217
+ async def get_file_impl(
218
+ request: Request,
219
+ user: UserRecord,
189
220
  path: str,
190
- download: bool = False, thumb: bool = False,
191
- user: UserRecord = Depends(get_current_user)
221
+ download: bool = False,
222
+ thumb: bool = False,
223
+ is_head = False,
192
224
  ):
193
225
  path = ensure_uri_compnents(path)
194
226
 
@@ -230,13 +262,58 @@ async def get_file(
230
262
  if not allow_access:
231
263
  raise HTTPException(status_code=403, detail=reason)
232
264
 
265
+ req_range = request.headers.get("Range", None)
266
+ if not req_range is None:
267
+ # handle range request
268
+ if not req_range.startswith("bytes="):
269
+ raise HTTPException(status_code=400, detail="Invalid range request")
270
+ range_str = req_range[6:].strip()
271
+ if "," in range_str:
272
+ raise HTTPException(status_code=400, detail="Multiple ranges not supported")
273
+ if "-" not in range_str:
274
+ raise HTTPException(status_code=400, detail="Invalid range request")
275
+ range_start, range_end = map(lambda x: int(x) if x != "" else -1 , range_str.split("-"))
276
+ else:
277
+ range_start, range_end = -1, -1
278
+
233
279
  if thumb:
234
- return await emit_thumbnail(path, download, create_time=file_record.create_time)
280
+ if (range_start != -1 or range_end != -1): logger.warning("Range request for thumbnail")
281
+ return await emit_thumbnail(path, download, create_time=file_record.create_time, is_head=is_head)
235
282
  else:
236
283
  if download:
237
- return await emit_file(file_record, 'application/octet-stream', "attachment")
284
+ return await emit_file(file_record, 'application/octet-stream', "attachment", is_head = is_head, range_start=range_start, range_end=range_end)
238
285
  else:
239
- return await emit_file(file_record, None, "inline")
286
+ return await emit_file(file_record, None, "inline", is_head = is_head, range_start=range_start, range_end=range_end)
287
+
288
+ @router_fs.get("/{path:path}")
289
+ @handle_exception
290
+ async def get_file(
291
+ request: Request,
292
+ path: str,
293
+ download: bool = False, thumb: bool = False,
294
+ user: UserRecord = Depends(get_current_user)
295
+ ):
296
+ return await get_file_impl(
297
+ request = request,
298
+ user = user, path = path, download = download, thumb = thumb
299
+ )
300
+
301
+ @router_fs.head("/{path:path}")
302
+ @handle_exception
303
+ async def head_file(
304
+ request: Request,
305
+ path: str,
306
+ download: bool = False, thumb: bool = False,
307
+ user: UserRecord = Depends(get_current_user)
308
+ ):
309
+ if path.startswith("_api/"):
310
+ raise HTTPException(status_code=405, detail="HEAD not supported for API")
311
+ if path.endswith("/"):
312
+ raise HTTPException(status_code=405, detail="HEAD not supported for directory")
313
+ return await get_file_impl(
314
+ request = request,
315
+ user = user, path = path, download = download, thumb = thumb, is_head = True
316
+ )
240
317
 
241
318
  @router_fs.put("/{path:path}")
242
319
  @handle_exception
@@ -360,11 +437,10 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
360
437
  logger.info(f"DELETE {path}, user: {user.username}")
361
438
 
362
439
  if path.endswith("/"):
363
- res = await db.delete_path(path, user if not user.is_admin else None)
440
+ res = await db.delete_path(path, user)
364
441
  else:
365
- res = await db.delete_file(path, user if not user.is_admin else None)
442
+ res = await db.delete_file(path, user)
366
443
 
367
- await delayed_log_activity(user.username)
368
444
  if res:
369
445
  return Response(status_code=200, content="Deleted")
370
446
  else:
@@ -456,16 +532,15 @@ async def update_file_meta(
456
532
  path = ensure_uri_compnents(path)
457
533
  if path.startswith("/"):
458
534
  path = path[1:]
459
- await delayed_log_activity(user.username)
460
535
 
461
536
  # file
462
537
  if not path.endswith("/"):
463
538
  if perm is not None:
464
539
  logger.info(f"Update permission of {path} to {perm}")
465
540
  await db.update_file_record(
466
- user = user,
467
541
  url = path,
468
- permission = FileReadPermission(perm)
542
+ permission = FileReadPermission(perm),
543
+ op_user = user,
469
544
  )
470
545
 
471
546
  if new_path is not None:
@@ -480,7 +555,7 @@ async def update_file_meta(
480
555
  new_path = ensure_uri_compnents(new_path)
481
556
  logger.info(f"Update path of {path} to {new_path}")
482
557
  # currently only move own file, with overwrite
483
- await db.move_path(user, path, new_path)
558
+ await db.move_path(path, new_path, user)
484
559
 
485
560
  return Response(status_code=200, content="OK")
486
561
 
lfss/src/utils.py CHANGED
@@ -109,6 +109,17 @@ def parse_storage_size(s: str) -> int:
109
109
  case 'g': return int(s[:-1]) * 1024**3
110
110
  case 't': return int(s[:-1]) * 1024**4
111
111
  case _: raise ValueError(f"Invalid file size string: {s}")
112
+ def fmt_storage_size(size: int) -> str:
113
+ """ Format the file size to human-readable format """
114
+ if size < 1024:
115
+ return f"{size}B"
116
+ if size < 1024**2:
117
+ return f"{size/1024:.2f}K"
118
+ if size < 1024**3:
119
+ return f"{size/1024**2:.2f}M"
120
+ if size < 1024**4:
121
+ return f"{size/1024**3:.2f}G"
122
+ return f"{size/1024**4:.2f}T"
112
123
 
113
124
  _FnReturnT = TypeVar('_FnReturnT')
114
125
  _AsyncReturnT = Awaitable[_FnReturnT]
@@ -136,4 +147,12 @@ def concurrent_wrap(executor=None):
136
147
  loop = asyncio.new_event_loop()
137
148
  return loop.run_until_complete(func(*args, **kwargs))
138
149
  return sync_fn
139
- return _concurrent_wrap
150
+ return _concurrent_wrap
151
+
152
+ # https://stackoverflow.com/a/279586/6775765
153
+ def static_vars(**kwargs):
154
+ def decorate(func):
155
+ for k in kwargs:
156
+ setattr(func, k, kwargs[k])
157
+ return func
158
+ return decorate
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
7
7
  Author-email: limengxun45@outlook.com
8
- Requires-Python: >=3.9
8
+ Requires-Python: >=3.10
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.9
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
13
12
  Classifier: Programming Language :: Python :: 3.12
@@ -4,24 +4,25 @@ docs/Permission.md,sha256=9r9nEmhqfz18RTS8FI0fZ9F0a31r86OoAyx3EQxxpk0,2317
4
4
  frontend/api.js,sha256=wUJNAkL8QigAiwR_jaMPUhCQEsL-lp0wZ6XeueYgunE,18049
5
5
  frontend/index.html,sha256=-k0bJ5FRqdl_H-O441D_H9E-iejgRCaL_z5UeYaS2qc,3384
6
6
  frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
7
- frontend/info.js,sha256=WhOGaeqMoezEAfg4nIpK26hvejC7AZ-ZDLiJmRj0kDk,5758
7
+ frontend/info.js,sha256=xGUJPCSrtDhuSu0ELLQZ77PmVWldg-prU1mwQGbdEoA,5797
8
8
  frontend/login.css,sha256=VMM0QfbDFYerxKWKSGhMI1yg5IRBXg0TTdLJEEhQZNk,355
9
9
  frontend/login.js,sha256=QoO8yKmBHDVP-ZomCMOaV7xVUVIhpl7esJrb6T5aHQE,2466
10
10
  frontend/popup.css,sha256=TJZYFW1ZcdD1IVTlNPYNtMWKPbN6XDbQ4hKBOFK8uLg,1284
11
11
  frontend/popup.js,sha256=3PgaGZmxSdV1E-D_MWgcR7aHWkcsHA1BNKSOkmP66tA,5191
12
- frontend/scripts.js,sha256=2YoMhrcAkI1bHihD_2EK6uCHZ1s0DiIR3FzZsh79x9A,21729
12
+ frontend/scripts.js,sha256=nWH6NgavZTVmjK44i2DeRi6mJzGSe4qeQPUbDaEVt58,21735
13
13
  frontend/state.js,sha256=vbNL5DProRKmSEY7xu9mZH6IY0PBenF8WGxPtGgDnLI,1680
14
14
  frontend/styles.css,sha256=xcNLqI3KBsY5TLnku8UIP0Jfr7QLajr1_KNlZj9eheM,4935
15
15
  frontend/thumb.css,sha256=rNsx766amYS2DajSQNabhpQ92gdTpNoQKmV69OKvtpI,295
16
- frontend/thumb.js,sha256=RQ_whXNwmkdG4SEbNQGeh488YYzqwoNYDc210hPeuhQ,5703
16
+ frontend/thumb.js,sha256=tX9LtxT6O2O6IUlpdvID6S973SUpWxgPVVqI9pwlVw8,6113
17
17
  frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
18
- lfss/api/__init__.py,sha256=MRzwISePOdq3of9IWGryVWX6coGkxeJ3OEh42Se4IYc,6029
19
- lfss/api/connector.py,sha256=tmcgKswE0sktBhanEDEc6mpuJA0de7C-DS2YqlpxHX4,10743
18
+ lfss/api/__init__.py,sha256=c_-GokKJ_3REgve16AwgXRfFyl9mwy7__FxCIIVlk1Q,6660
19
+ lfss/api/connector.py,sha256=tCUwTlbzTwHvPnFb8nlnc6LEnrXwdCnCCThyBISt2Tg,11319
20
+ lfss/cli/__init__.py,sha256=lPwPmqpa7EXQ4zlU7E7LOe6X2kw_xATGdwoHphUEirA,827
20
21
  lfss/cli/balance.py,sha256=R2rbO2tg9TVnnQIVeU0GJVeMS-5LDhEdk4mbOE9qGq0,4121
21
- lfss/cli/cli.py,sha256=8VKe41m_LhVSFxGlvgBxdz55sjscLNbbkNX1fOnmES4,4618
22
+ lfss/cli/cli.py,sha256=LxUrviHtsqi-vs_GWZw2qRs9dBNvx9PSQHLW6SwUmhA,8167
22
23
  lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
23
24
  lfss/cli/serve.py,sha256=T-jz_PJcaY9FJRRfyWV9MbjDI73YdOOCn4Nr2PO-s0c,993
24
- lfss/cli/user.py,sha256=wlR-xcJKCtr_y5QgYO9GM0JyDCKooIRlsAxw2eilPfs,3418
25
+ lfss/cli/user.py,sha256=uqHQ7onddTjJAYg3B1DIc8hDl0aCkIMZolLKhQrBd0k,4046
25
26
  lfss/cli/vacuum.py,sha256=4TMMYC_5yEt7jeaFTVC3iX0X6aUzYXBiCsrcIYgw_uA,3695
26
27
  lfss/sql/init.sql,sha256=C-JtQAlaOjESI8uoF1Y_9dKukEVSw5Ll-7yA3gG-XHU,1210
27
28
  lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
@@ -29,15 +30,15 @@ lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
30
  lfss/src/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
30
31
  lfss/src/config.py,sha256=7k_6L_aG7vD-lVcmgpwMIg-ggpMMDNU8IbaB4y1effE,861
31
32
  lfss/src/connection_pool.py,sha256=4YULPcmndy33FyBSo9w1fZjAlBX2q-xPP27xJOTA06I,5228
32
- lfss/src/database.py,sha256=Cexv6r9sZl29hWzFyL_J_kWz9roUbut6A246Zc4ORs0,35885
33
- lfss/src/datatype.py,sha256=q2lc8BJaB2sS7gtqPMqxM625mckgI2SmvuqbadiObLY,2158
33
+ lfss/src/database.py,sha256=zoiBm7CVHHV4TqwmK6lPnZvK9mzDNtrNvAJCRaIYMU8,36302
34
+ lfss/src/datatype.py,sha256=yyOcxhGwz-EJi003f8hGl82EJuY4F92y6fSX6cK60Bc,2126
34
35
  lfss/src/error.py,sha256=Bh_GUtuNsMSMIKbFreU4CfZzL96TZdNAIcgmDxvGbQ0,333
35
36
  lfss/src/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
36
- lfss/src/server.py,sha256=mUu0WbiGdM08Jos7a4r_e9ND5sK_tNDEv1VGRPIHvLk,21206
37
+ lfss/src/server.py,sha256=YLsp6bab7q0I2hI4uUYIiWc2S0k6d6bbMaweg6VbVV4,23743
37
38
  lfss/src/stat.py,sha256=WlRiiAl0rHX9oPi3thi6K4GKn70WHpClSDCt7iZUow4,3197
38
39
  lfss/src/thumb.py,sha256=qjCNMpnCozMuzkhm-2uAYy1eAuYTeWG6xqs-13HX-7k,3266
39
- lfss/src/utils.py,sha256=nal2rpr00jq1PeFhGQXkvU0FIbtRhXTj8VmbeIyRyLI,5184
40
- lfss-0.8.1.dist-info/METADATA,sha256=PMNu6iNXnpYU6Lyxvlr9-e-ypSj71dygYyqH3mTHzE0,2108
41
- lfss-0.8.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
42
- lfss-0.8.1.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
43
- lfss-0.8.1.dist-info/RECORD,,
40
+ lfss/src/utils.py,sha256=DxjHabdiISMkrm1WQlpsZFKL3by6YrzBNQaDt_uZlRk,5744
41
+ lfss-0.8.3.dist-info/METADATA,sha256=2Q3LdTB3vX_i8kpXIJ9SmMv-GLC1kslzN0RUBW9ksSA,2059
42
+ lfss-0.8.3.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
43
+ lfss-0.8.3.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
44
+ lfss-0.8.3.dist-info/RECORD,,
File without changes