lfss 0.8.0__py3-none-any.whl → 0.8.2__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/api.js CHANGED
@@ -73,7 +73,8 @@ export default class Connector {
73
73
  method: 'PUT',
74
74
  headers: {
75
75
  'Authorization': 'Bearer ' + this.config.token,
76
- 'Content-Type': 'application/octet-stream'
76
+ 'Content-Type': 'application/octet-stream',
77
+ 'Content-Length': fileBytes.byteLength
77
78
  },
78
79
  body: fileBytes
79
80
  });
@@ -83,6 +84,38 @@ export default class Connector {
83
84
  return (await res.json()).url;
84
85
  }
85
86
 
87
+ /**
88
+ * @param {string} path - the path to the file (url)
89
+ * @param {File} file - the file to upload
90
+ * @returns {Promise<string>} - the promise of the request, the url of the file
91
+ */
92
+ async post(path, file, {
93
+ conflict = 'abort',
94
+ permission = 0
95
+ } = {}){
96
+ if (path.startsWith('/')){ path = path.slice(1); }
97
+ const dst = new URL(this.config.endpoint + '/' + path);
98
+ dst.searchParams.append('conflict', conflict);
99
+ dst.searchParams.append('permission', permission);
100
+ // post as multipart form data
101
+ const formData = new FormData();
102
+ formData.append('file', file);
103
+ const res = await fetch(dst.toString(), {
104
+ method: 'POST',
105
+ // don't include the content type, let the browser handle it
106
+ // https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/
107
+ headers: {
108
+ 'Authorization': 'Bearer ' + this.config.token,
109
+ },
110
+ body: formData
111
+ });
112
+
113
+ if (res.status != 200 && res.status != 201){
114
+ throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await res.json()}`);
115
+ }
116
+ return (await res.json()).url;
117
+ }
118
+
86
119
  /**
87
120
  * @param {string} path - the path to the file (url), should end with .json
88
121
  * @param {Objec} data - the data to upload
@@ -422,4 +455,28 @@ export async function listPath(conn, path, {
422
455
  dirs: dirCount,
423
456
  files: fileCount
424
457
  }];
425
- };
458
+ };
459
+
460
+ /**
461
+ * a function to wrap the upload function into one
462
+ * it will return the url of the file
463
+ *
464
+ * @typedef {Object} UploadOptions
465
+ * @property {string} conflict - the conflict resolution strategy, can be 'abort', 'replace', 'rename'
466
+ * @property {number} permission - the permission of the file, can be 0, 1, 2, 3
467
+ *
468
+ * @param {Connector} conn - the connector to the API
469
+ * @param {string} path - the path to the file (url)
470
+ * @param {File} file - the file to upload
471
+ * @param {UploadOptions} options - the options for the request
472
+ * @returns {Promise<string>} - the promise of the request, the url of the file
473
+ */
474
+ export async function uploadFile(conn, path, file, {
475
+ conflict = 'abort',
476
+ permission = 0
477
+ } = {}){
478
+ if (file.size < 1024 * 1024 * 10){
479
+ return await conn.put(path, file, {conflict, permission});
480
+ }
481
+ return await conn.post(path, file, {conflict, permission});
482
+ }
frontend/scripts.js CHANGED
@@ -1,4 +1,4 @@
1
- import { permMap, listPath } from './api.js';
1
+ import { permMap, listPath, uploadFile } from './api.js';
2
2
  import { showFloatingWindowLineInput, showPopup } from './popup.js';
3
3
  import { formatSize, decodePathURI, ensurePathURI, getRandomString, cvtGMT2Local, debounce, encodePathURI, asHtmlText } from './utils.js';
4
4
  import { showInfoPanel, showDirInfoPanel } from './info.js';
@@ -132,7 +132,7 @@ uploadButton.addEventListener('click', () => {
132
132
  }
133
133
  path = path + fileName;
134
134
  showPopup('Uploading...', {level: 'info', timeout: 3000});
135
- conn.put(path, file, {'conflict': 'overwrite'})
135
+ uploadFile(conn, path, file, {'conflict': 'overwrite'})
136
136
  .then(() => {
137
137
  refreshFileList();
138
138
  uploadFileNameInput.value = '';
@@ -178,10 +178,10 @@ Are you sure you want to proceed?
178
178
  `)){ return; }
179
179
 
180
180
  let counter = 0;
181
- async function uploadFile(...args){
181
+ async function uploadFileFn(...args){
182
182
  const [file, path] = args;
183
183
  try{
184
- await conn.put(path, file, {conflict: 'overwrite'});
184
+ await uploadFile(conn, path, file, {conflict: 'overwrite'});
185
185
  }
186
186
  catch (err){
187
187
  showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
@@ -194,7 +194,7 @@ Are you sure you want to proceed?
194
194
  for (let i = 0; i < files.length; i++){
195
195
  const file = files[i];
196
196
  const path = dstPath + file.name;
197
- promises.push(uploadFile(file, path));
197
+ promises.push(uploadFileFn(file, path));
198
198
  }
199
199
  showPopup('Uploading multiple files...', {level: 'info', timeout: 3000});
200
200
  Promise.all(promises).then(
lfss/api/__init__.py CHANGED
@@ -16,18 +16,29 @@ 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
- with open(file_path, 'rb') as f:
22
- blob = f.read()
23
- connector.put(dst_url, blob, **put_kwargs)
26
+ fsize = os.path.getsize(file_path)
27
+ if fsize < 32 * 1024 * 1024: # 32MB
28
+ with open(file_path, 'rb') as f:
29
+ blob = f.read()
30
+ connector.put(dst_url, blob, **put_kwargs)
31
+ else:
32
+ connector.post(dst_url, file_path, **put_kwargs)
24
33
  break
25
34
  except Exception as e:
26
35
  if isinstance(e, KeyboardInterrupt):
27
36
  raise e
28
37
  if verbose:
29
38
  print(f"Error uploading {file_path}: {e}, retrying...")
30
- 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
31
42
  this_try += 1
32
43
  finally:
33
44
  time.sleep(interval)
@@ -91,26 +102,43 @@ def download_file(
91
102
  ) -> tuple[bool, str]:
92
103
  this_try = 0
93
104
  error_msg = ""
105
+ assert not src_url.endswith('/'), "Source URL must not end with a slash."
94
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
+
95
111
  if not overwrite and os.path.exists(file_path):
96
112
  if verbose:
97
113
  print(f"File {file_path} already exists, skipping download.")
98
114
  return True, error_msg
99
115
  try:
100
- blob = connector.get(src_url)
101
- if blob is None:
116
+ fmeta = connector.get_metadata(src_url)
117
+ if fmeta is None:
102
118
  error_msg = "File not found."
103
119
  return False, error_msg
120
+
104
121
  pathlib.Path(file_path).parent.mkdir(parents=True, exist_ok=True)
105
- with open(file_path, 'wb') as f:
106
- f.write(blob)
122
+ fsize = fmeta.file_size # type: ignore
123
+ if fsize < 32 * 1024 * 1024: # 32MB
124
+ blob = connector.get(src_url)
125
+ assert blob is not None
126
+ with open(file_path, 'wb') as f:
127
+ f.write(blob)
128
+ else:
129
+ with open(file_path, 'wb') as f:
130
+ for chunk in connector.get_stream(src_url):
131
+ f.write(chunk)
107
132
  break
133
+
108
134
  except Exception as e:
109
135
  if isinstance(e, KeyboardInterrupt):
110
136
  raise e
111
137
  if verbose:
112
138
  print(f"Error downloading {src_url}: {e}, retrying...")
113
- 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
114
142
  this_try += 1
115
143
  finally:
116
144
  time.sleep(interval)
lfss/api/connector.py CHANGED
@@ -4,6 +4,7 @@ import os
4
4
  import requests
5
5
  import requests.adapters
6
6
  import urllib.parse
7
+ from tempfile import SpooledTemporaryFile
7
8
  from lfss.src.error import PathNotFoundError
8
9
  from lfss.src.datatype import (
9
10
  FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents,
@@ -95,6 +96,42 @@ class Connector:
95
96
  )
96
97
  return response.json()
97
98
 
99
+ def post(self, path, file: str | bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
100
+ """
101
+ Uploads a file to the specified path,
102
+ using the POST method, with form-data/multipart.
103
+ file can be a path to a file on disk, or bytes.
104
+ """
105
+
106
+ # Skip ahead by checking if the file already exists
107
+ if conflict == 'skip-ahead':
108
+ exists = self.get_metadata(path)
109
+ if exists is None:
110
+ conflict = 'skip'
111
+ else:
112
+ return {'status': 'skipped', 'path': path}
113
+
114
+ if isinstance(file, str):
115
+ assert os.path.exists(file), "File does not exist on disk"
116
+ fsize = os.path.getsize(file)
117
+
118
+ with open(file, 'rb') if isinstance(file, str) else SpooledTemporaryFile(max_size=1024*1024*32) as fp:
119
+
120
+ if isinstance(file, bytes):
121
+ fsize = len(file)
122
+ fp.write(file)
123
+ fp.seek(0)
124
+
125
+ # https://stackoverflow.com/questions/12385179/
126
+ print(f"Uploading {fsize} bytes")
127
+ response = self._fetch_factory('POST', path, search_params={
128
+ 'permission': int(permission),
129
+ 'conflict': conflict
130
+ })(
131
+ files={'file': fp},
132
+ )
133
+ return response.json()
134
+
98
135
  def put_json(self, path: str, data: dict, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
99
136
  """Uploads a JSON file to the specified path."""
100
137
  assert path.endswith('.json'), "Path must end with .json"
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/config.py CHANGED
@@ -18,7 +18,7 @@ if __env_large_file is not None:
18
18
  LARGE_FILE_BYTES = parse_storage_size(__env_large_file)
19
19
  else:
20
20
  LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
21
- MAX_FILE_BYTES = 512 * 1024 * 1024 # 512MB
21
+ MAX_MEM_FILE_BYTES = 128 * 1024 * 1024 # 128MB
22
22
  MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
23
23
  CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming (on large files)
24
24
 
lfss/src/database.py CHANGED
@@ -8,18 +8,25 @@ import zipfile, io, asyncio
8
8
 
9
9
  import aiosqlite, aiofiles
10
10
  import aiofiles.os
11
+ import mimetypes, mimesniff
11
12
 
12
13
  from .connection_pool import execute_sql, unique_cursor, transaction
13
14
  from .datatype import (
14
15
  UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents,
15
16
  FileSortKey, DirSortKey, isValidFileSortKey, isValidDirSortKey
16
17
  )
17
- from .config import LARGE_BLOB_DIR, CHUNK_SIZE
18
+ from .config import LARGE_BLOB_DIR, CHUNK_SIZE, LARGE_FILE_BYTES, MAX_MEM_FILE_BYTES
18
19
  from .log import get_logger
19
20
  from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async
20
21
  from .error import *
21
22
 
22
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
+
23
30
  logger = get_logger('database', global_instance=True)
24
31
  _cur: aiosqlite.Cursor
25
32
 
@@ -205,7 +212,7 @@ class FileConn(DBObjectBase):
205
212
  return DirectoryRecord(dir_url)
206
213
  else:
207
214
  return await self.get_path_record(dir_url)
208
- 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]
209
216
  return dirs
210
217
 
211
218
  async def count_path_files(self, url: str, flat: bool = False):
@@ -285,8 +292,7 @@ class FileConn(DBObjectBase):
285
292
  async def user_size(self, user_id: int) -> int:
286
293
  cursor = await self.cur.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, ))
287
294
  res = await cursor.fetchone()
288
- if res is None:
289
- return -1
295
+ if res is None: return 0
290
296
  return res[0]
291
297
  async def _user_size_inc(self, user_id: int, inc: int):
292
298
  self.logger.debug(f"Increasing user {user_id} size by {inc}")
@@ -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]
@@ -521,72 +523,77 @@ class Database:
521
523
  await execute_sql(conn, 'init.sql')
522
524
  return self
523
525
 
524
- async def update_file_record(self, user: UserRecord, url: str, permission: FileReadPermission):
526
+ async def update_file_record(self, url: str, permission: FileReadPermission, op_user: Optional[UserRecord] = None):
525
527
  validate_url(url)
526
528
  async with transaction() as conn:
527
529
  fconn = FileConn(conn)
528
530
  r = await fconn.get_file_record(url)
529
531
  if r is None:
530
532
  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}")
533
+ if op_user is not None:
534
+ if r.owner_id != op_user.id and not op_user.is_admin:
535
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot update file {url}")
533
536
  await fconn.update_file_record(url, permission=permission)
534
537
 
535
538
  async def save_file(
536
539
  self, u: int | str, url: str,
537
- blob: bytes | AsyncIterable[bytes],
540
+ blob_stream: AsyncIterable[bytes],
538
541
  permission: FileReadPermission = FileReadPermission.UNSET,
539
- mime_type: str = 'application/octet-stream'
540
- ):
542
+ mime_type: Optional[str] = None
543
+ ) -> int:
541
544
  """
542
- if file_size is not provided, the blob must be bytes
545
+ Save a file to the database.
546
+ Will check file size and user storage limit,
547
+ should check permission before calling this method.
543
548
  """
544
549
  validate_url(url)
545
550
  async with unique_cursor() as cur:
546
551
  user = await get_user(cur, u)
547
- if user is None:
548
- return
549
-
550
- # check if the user is the owner of the path, or is admin
551
- if url.startswith('/'):
552
- url = url[1:]
553
- first_component = url.split('/')[0]
554
- if first_component != user.username:
555
- if not user.is_admin:
556
- raise PermissionDeniedError(f"Permission denied: {user.username} cannot write to {url}")
557
- else:
558
- if await get_user(cur, first_component) is None:
559
- raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
552
+ assert user is not None, f"User {u} not found"
560
553
 
561
554
  fconn_r = FileConn(cur)
562
555
  user_size_used = await fconn_r.user_size(user.id)
563
556
 
564
- if isinstance(blob, bytes):
565
- file_size = len(blob)
566
- if user_size_used + file_size > user.max_storage:
567
- raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
568
- f_id = uuid.uuid4().hex
569
-
570
- async with transaction() as w_cur:
571
- fconn_w = FileConn(w_cur)
572
- await fconn_w.set_file_blob(f_id, blob)
573
- await fconn_w.set_file_record(
574
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
575
- permission=permission, external=False, mime_type=mime_type)
576
- else:
577
- assert isinstance(blob, AsyncIterable)
578
- f_id = uuid.uuid4().hex
579
- file_size = await FileConn.set_file_blob_external(f_id, blob)
557
+ f_id = uuid.uuid4().hex
558
+ async with aiofiles.tempfile.SpooledTemporaryFile(max_size=MAX_MEM_FILE_BYTES) as f:
559
+ async for chunk in blob_stream:
560
+ await f.write(chunk)
561
+ file_size = await f.tell()
580
562
  if user_size_used + file_size > user.max_storage:
581
- await FileConn.delete_file_blob_external(f_id)
582
563
  raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
583
564
 
584
- async with transaction() as w_cur:
585
- await FileConn(w_cur).set_file_record(
586
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
587
- permission=permission, external=True, mime_type=mime_type)
588
-
589
- await delayed_log_activity(user.username)
565
+ # check mime type
566
+ if mime_type is None:
567
+ mime_type, _ = mimetypes.guess_type(url)
568
+ if mime_type is None:
569
+ await f.seek(0)
570
+ mime_type = mimesniff.what(await f.read(1024))
571
+ if mime_type is None:
572
+ mime_type = 'application/octet-stream'
573
+ await f.seek(0)
574
+
575
+ if file_size < LARGE_FILE_BYTES:
576
+ blob = await f.read()
577
+ async with transaction() as w_cur:
578
+ fconn_w = FileConn(w_cur)
579
+ await fconn_w.set_file_blob(f_id, blob)
580
+ await fconn_w.set_file_record(
581
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
582
+ permission=permission, external=False, mime_type=mime_type)
583
+
584
+ else:
585
+ async def blob_stream_tempfile():
586
+ nonlocal f
587
+ while True:
588
+ chunk = await f.read(CHUNK_SIZE)
589
+ if not chunk: break
590
+ yield chunk
591
+ await FileConn.set_file_blob_external(f_id, blob_stream_tempfile())
592
+ async with transaction() as w_cur:
593
+ await FileConn(w_cur).set_file_record(
594
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
595
+ permission=permission, external=True, mime_type=mime_type)
596
+ return file_size
590
597
 
591
598
  async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
592
599
  validate_url(url)
@@ -598,11 +605,8 @@ class Database:
598
605
  if not r.external:
599
606
  raise ValueError(f"File {url} is not stored externally, should use read_file instead")
600
607
  ret = fconn.get_file_blob_external(r.file_id)
601
-
602
- await delayed_log_access(url)
603
608
  return ret
604
609
 
605
-
606
610
  async def read_file(self, url: str) -> bytes:
607
611
  validate_url(url)
608
612
 
@@ -618,11 +622,9 @@ class Database:
618
622
  blob = await fconn.get_file_blob(f_id)
619
623
  if blob is None:
620
624
  raise FileNotFoundError(f"File {url} data not found")
621
-
622
- await delayed_log_access(url)
623
625
  return blob
624
626
 
625
- async def delete_file(self, url: str, assure_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
627
+ async def delete_file(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
626
628
  validate_url(url)
627
629
 
628
630
  async with transaction() as cur:
@@ -630,10 +632,10 @@ class Database:
630
632
  r = await fconn.delete_file_record(url)
631
633
  if r is None:
632
634
  return None
633
- if assure_user is not None:
634
- if r.owner_id != assure_user.id:
635
+ if op_user is not None:
636
+ if r.owner_id != op_user.id and not op_user.is_admin:
635
637
  # will rollback
636
- raise PermissionDeniedError(f"Permission denied: {assure_user.username} cannot delete file {url}")
638
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot delete file {url}")
637
639
  f_id = r.file_id
638
640
  if r.external:
639
641
  await fconn.delete_file_blob_external(f_id)
@@ -641,7 +643,7 @@ class Database:
641
643
  await fconn.delete_file_blob(f_id)
642
644
  return r
643
645
 
644
- async def move_file(self, old_url: str, new_url: str, ensure_user: Optional[UserRecord] = None):
646
+ async def move_file(self, old_url: str, new_url: str, op_user: Optional[UserRecord] = None):
645
647
  validate_url(old_url)
646
648
  validate_url(new_url)
647
649
 
@@ -650,12 +652,16 @@ class Database:
650
652
  r = await fconn.get_file_record(old_url)
651
653
  if r is None:
652
654
  raise FileNotFoundError(f"File {old_url} not found")
653
- if ensure_user is not None:
654
- if r.owner_id != ensure_user.id:
655
- raise PermissionDeniedError(f"Permission denied: {ensure_user.username} cannot move file {old_url}")
655
+ if op_user is not None:
656
+ if r.owner_id != op_user.id:
657
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
656
658
  await fconn.move_file(old_url, new_url)
659
+
660
+ new_mime, _ = mimetypes.guess_type(new_url)
661
+ if not new_mime is None:
662
+ await fconn.update_file_record(new_url, mime_type=new_mime)
657
663
 
658
- async def move_path(self, user: UserRecord, old_url: str, new_url: str):
664
+ async def move_path(self, old_url: str, new_url: str, op_user: UserRecord):
659
665
  validate_url(old_url, is_file=False)
660
666
  validate_url(new_url, is_file=False)
661
667
 
@@ -669,20 +675,20 @@ class Database:
669
675
 
670
676
  async with transaction() as cur:
671
677
  first_component = new_url.split('/')[0]
672
- if not (first_component == user.username or user.is_admin):
673
- raise PermissionDeniedError(f"Permission denied: path must start with {user.username}")
674
- elif user.is_admin:
678
+ if not (first_component == op_user.username or op_user.is_admin):
679
+ raise PermissionDeniedError(f"Permission denied: path must start with {op_user.username}")
680
+ elif op_user.is_admin:
675
681
  uconn = UserConn(cur)
676
682
  _is_user = await uconn.get_user(first_component)
677
683
  if not _is_user:
678
684
  raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
679
685
 
680
686
  # check if old path is under user's directory (non-admin)
681
- if not old_url.startswith(user.username + '/') and not user.is_admin:
682
- raise PermissionDeniedError(f"Permission denied: {user.username} cannot move path {old_url}")
687
+ if not old_url.startswith(op_user.username + '/') and not op_user.is_admin:
688
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move path {old_url}")
683
689
 
684
690
  fconn = FileConn(cur)
685
- await fconn.move_path(old_url, new_url, 'overwrite', user.id)
691
+ await fconn.move_path(old_url, new_url, 'overwrite', op_user.id)
686
692
 
687
693
  async def __batch_delete_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
688
694
  # https://github.com/langchain-ai/langchain/issues/10321
@@ -702,13 +708,13 @@ class Database:
702
708
  await fconn.delete_file_blob_external(external_ids[i])
703
709
  await asyncio.gather(del_internal(), del_external())
704
710
 
705
- async def delete_path(self, url: str, under_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
711
+ async def delete_path(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
706
712
  validate_url(url, is_file=False)
707
- user_id = under_user.id if under_user is not None else None
713
+ from_owner_id = op_user.id if op_user is not None and not op_user.is_admin else None
708
714
 
709
715
  async with transaction() as cur:
710
716
  fconn = FileConn(cur)
711
- records = await fconn.delete_path_records(url, user_id)
717
+ records = await fconn.delete_path_records(url, from_owner_id)
712
718
  if not records:
713
719
  return None
714
720
  await self.__batch_delete_file_blobs(fconn, records)
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
@@ -1,24 +1,23 @@
1
1
  from typing import Optional, Literal
2
2
  from functools import wraps
3
3
 
4
- from fastapi import FastAPI, APIRouter, Depends, Request, Response
4
+ from fastapi import FastAPI, APIRouter, Depends, Request, Response, UploadFile
5
5
  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
- import mimetypes
13
11
  from contextlib import asynccontextmanager
14
12
 
15
13
  from .error import *
16
14
  from .log import get_logger
17
15
  from .stat import RequestDB
18
- from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_FILE_BYTES, CHUNK_SIZE
16
+ from .config import MAX_BUNDLE_BYTES, CHUNK_SIZE
19
17
  from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
20
18
  from .connection_pool import global_connection_init, global_connection_close, unique_cursor
21
- from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn, delayed_log_activity
19
+ from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn
20
+ from .database import delayed_log_activity, delayed_log_access
22
21
  from .datatype import (
23
22
  FileReadPermission, FileRecord, UserRecord, PathContents,
24
23
  FileSortKey, DirSortKey
@@ -81,6 +80,10 @@ async def get_current_user(
81
80
 
82
81
  if not user:
83
82
  raise HTTPException(status_code=401, detail="Invalid token")
83
+
84
+ if not user.id == 0:
85
+ await delayed_log_activity(user.username)
86
+
84
87
  return user
85
88
 
86
89
  async def registered_user(user: UserRecord = Depends(get_current_user)):
@@ -166,6 +169,8 @@ async def emit_file(
166
169
  media_type = file_record.mime_type
167
170
  path = file_record.url
168
171
  fname = path.split("/")[-1]
172
+
173
+ await delayed_log_access(path)
169
174
  if not file_record.external:
170
175
  fblob = await db.read_file(path)
171
176
  return Response(
@@ -246,18 +251,21 @@ async def put_file(
246
251
  path: str,
247
252
  conflict: Literal["overwrite", "skip", "abort"] = "abort",
248
253
  permission: int = 0,
249
- user: UserRecord = Depends(registered_user)):
254
+ user: UserRecord = Depends(registered_user)
255
+ ):
250
256
  path = ensure_uri_compnents(path)
251
- if not path.startswith(f"{user.username}/") and not user.is_admin:
252
- logger.debug(f"Reject put request from {user.username} to {path}")
253
- raise HTTPException(status_code=403, detail="Permission denied")
254
-
255
- content_length = request.headers.get("Content-Length")
256
- if content_length is not None:
257
- content_length = int(content_length)
258
- if content_length > MAX_FILE_BYTES:
259
- logger.debug(f"Reject put request from {user.username} to {path}, file too large")
260
- raise HTTPException(status_code=413, detail="File too large")
257
+ assert not path.endswith("/"), "Path must not end with /"
258
+ if not path.startswith(f"{user.username}/"):
259
+ if not user.is_admin:
260
+ logger.debug(f"Reject put request from {user.username} to {path}")
261
+ raise HTTPException(status_code=403, detail="Permission denied")
262
+ else:
263
+ first_comp = path.split("/")[0]
264
+ async with unique_cursor() as c:
265
+ uconn = UserConn(c)
266
+ owner = await uconn.get_user(first_comp)
267
+ if not owner:
268
+ raise HTTPException(status_code=404, detail="Owner not found")
261
269
 
262
270
  logger.info(f"PUT {path}, user: {user.username}")
263
271
  exists_flag = False
@@ -280,47 +288,73 @@ async def put_file(
280
288
  # check content-type
281
289
  content_type = request.headers.get("Content-Type")
282
290
  logger.debug(f"Content-Type: {content_type}")
283
- if content_type == "application/json":
284
- body = await request.json()
285
- blobs = json.dumps(body).encode('utf-8')
286
- elif content_type == "application/x-www-form-urlencoded":
287
- # may not work...
288
- body = await request.form()
289
- file = body.get("file")
290
- if isinstance(file, str) or file is None:
291
- raise HTTPException(status_code=400, detail="Invalid form data, file required")
292
- blobs = await file.read()
293
- elif content_type == "application/octet-stream":
294
- blobs = await request.body()
295
- else:
296
- blobs = await request.body()
291
+ if not (content_type == "application/octet-stream" or content_type == "application/json"):
292
+ raise HTTPException(status_code=415, detail="Unsupported content type, put request must be application/json or application/octet-stream")
297
293
 
298
- # check file type
299
- assert not path.endswith("/"), "Path must be a file"
300
- fname = path.split("/")[-1]
301
- mime_t, _ = mimetypes.guess_type(fname)
302
- if mime_t is None:
303
- mime_t = mimesniff.what(blobs)
304
- if mime_t is None:
305
- mime_t = "application/octet-stream"
306
-
307
- if len(blobs) > LARGE_FILE_BYTES:
308
- async def blob_reader():
309
- for b in range(0, len(blobs), CHUNK_SIZE):
310
- yield blobs[b:b+CHUNK_SIZE]
311
- await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission), mime_type = mime_t)
312
- else:
313
- await db.save_file(user.id, path, blobs, permission = FileReadPermission(permission), mime_type=mime_t)
294
+ async def blob_reader():
295
+ nonlocal request
296
+ async for chunk in request.stream():
297
+ yield chunk
298
+
299
+ await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
314
300
 
315
301
  # https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
316
- if exists_flag:
317
- return Response(status_code=201, headers={
318
- "Content-Type": "application/json",
319
- }, content=json.dumps({"url": path}))
320
- else:
321
- return Response(status_code=200, headers={
322
- "Content-Type": "application/json",
323
- }, content=json.dumps({"url": path}))
302
+ return Response(status_code=200 if exists_flag else 201, headers={
303
+ "Content-Type": "application/json",
304
+ }, content=json.dumps({"url": path}))
305
+
306
+ # using form-data instead of raw body
307
+ @router_fs.post("/{path:path}")
308
+ @handle_exception
309
+ async def post_file(
310
+ path: str,
311
+ file: UploadFile,
312
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
313
+ permission: int = 0,
314
+ user: UserRecord = Depends(registered_user)
315
+ ):
316
+ path = ensure_uri_compnents(path)
317
+ assert not path.endswith("/"), "Path must not end with /"
318
+ if not path.startswith(f"{user.username}/"):
319
+ if not user.is_admin:
320
+ logger.debug(f"Reject put request from {user.username} to {path}")
321
+ raise HTTPException(status_code=403, detail="Permission denied")
322
+ else:
323
+ first_comp = path.split("/")[0]
324
+ async with unique_cursor() as conn:
325
+ uconn = UserConn(conn)
326
+ owner = await uconn.get_user(first_comp)
327
+ if not owner:
328
+ raise HTTPException(status_code=404, detail="Owner not found")
329
+
330
+ logger.info(f"POST {path}, user: {user.username}")
331
+ exists_flag = False
332
+ async with unique_cursor() as conn:
333
+ fconn = FileConn(conn)
334
+ file_record = await fconn.get_file_record(path)
335
+
336
+ if file_record:
337
+ if conflict == "abort":
338
+ raise HTTPException(status_code=409, detail="File exists")
339
+ if conflict == "skip":
340
+ return Response(status_code=200, headers={
341
+ "Content-Type": "application/json",
342
+ }, content=json.dumps({"url": path}))
343
+ exists_flag = True
344
+ if not user.is_admin and not file_record.owner_id == user.id:
345
+ raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
346
+ await db.delete_file(path)
347
+
348
+ async def blob_reader():
349
+ nonlocal file
350
+ while (chunk := await file.read(CHUNK_SIZE)):
351
+ yield chunk
352
+
353
+ await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
354
+ return Response(status_code=200 if exists_flag else 201, headers={
355
+ "Content-Type": "application/json",
356
+ }, content=json.dumps({"url": path}))
357
+
324
358
 
325
359
  @router_fs.delete("/{path:path}")
326
360
  @handle_exception
@@ -332,11 +366,10 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
332
366
  logger.info(f"DELETE {path}, user: {user.username}")
333
367
 
334
368
  if path.endswith("/"):
335
- res = await db.delete_path(path, user if not user.is_admin else None)
369
+ res = await db.delete_path(path, user)
336
370
  else:
337
- res = await db.delete_file(path, user if not user.is_admin else None)
371
+ res = await db.delete_file(path, user)
338
372
 
339
- await delayed_log_activity(user.username)
340
373
  if res:
341
374
  return Response(status_code=200, content="Deleted")
342
375
  else:
@@ -428,16 +461,15 @@ async def update_file_meta(
428
461
  path = ensure_uri_compnents(path)
429
462
  if path.startswith("/"):
430
463
  path = path[1:]
431
- await delayed_log_activity(user.username)
432
464
 
433
465
  # file
434
466
  if not path.endswith("/"):
435
467
  if perm is not None:
436
468
  logger.info(f"Update permission of {path} to {perm}")
437
469
  await db.update_file_record(
438
- user = user,
439
470
  url = path,
440
- permission = FileReadPermission(perm)
471
+ permission = FileReadPermission(perm),
472
+ op_user = user,
441
473
  )
442
474
 
443
475
  if new_path is not None:
@@ -452,7 +484,7 @@ async def update_file_meta(
452
484
  new_path = ensure_uri_compnents(new_path)
453
485
  logger.info(f"Update path of {path} to {new_path}")
454
486
  # currently only move own file, with overwrite
455
- await db.move_path(user, path, new_path)
487
+ await db.move_path(path, new_path, user)
456
488
 
457
489
  return Response(status_code=200, content="OK")
458
490
 
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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -16,6 +16,7 @@ Requires-Dist: aiosqlite (==0.*)
16
16
  Requires-Dist: fastapi (==0.*)
17
17
  Requires-Dist: mimesniff (==1.*)
18
18
  Requires-Dist: pillow
19
+ Requires-Dist: python-multipart
19
20
  Requires-Dist: requests (==2.*)
20
21
  Requires-Dist: uvicorn (==0.*)
21
22
  Project-URL: Repository, https://github.com/MenxLi/lfss
@@ -1,7 +1,7 @@
1
1
  Readme.md,sha256=LpbTvUWjCOv4keMNDrZvEnNAmCQnvaxvlq2srWixXn0,1299
2
2
  docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
3
  docs/Permission.md,sha256=9r9nEmhqfz18RTS8FI0fZ9F0a31r86OoAyx3EQxxpk0,2317
4
- frontend/api.js,sha256=9cR8ddaoF-ulauQ1tcISV2nmfakkplC7uS8-lWFqU58,15820
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
7
  frontend/info.js,sha256=WhOGaeqMoezEAfg4nIpK26hvejC7AZ-ZDLiJmRj0kDk,5758
@@ -9,35 +9,36 @@ 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=PFgAZC-frV-qs6sYCb1ZzH_CAa7in17qbfqYiaIxNmA,21697
12
+ frontend/scripts.js,sha256=2YoMhrcAkI1bHihD_2EK6uCHZ1s0DiIR3FzZsh79x9A,21729
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
16
  frontend/thumb.js,sha256=RQ_whXNwmkdG4SEbNQGeh488YYzqwoNYDc210hPeuhQ,5703
17
17
  frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
18
- lfss/api/__init__.py,sha256=YI1_9nyW0E5lyXn_PmmIzIff1ccBC-KCA0twpsKDRIY,5453
19
- lfss/api/connector.py,sha256=q9CJBOmN83tfpwI1IclSzq_lzI4Kq1SOKC3S5H-vuWo,9301
18
+ lfss/api/__init__.py,sha256=c_-GokKJ_3REgve16AwgXRfFyl9mwy7__FxCIIVlk1Q,6660
19
+ lfss/api/connector.py,sha256=tmcgKswE0sktBhanEDEc6mpuJA0de7C-DS2YqlpxHX4,10743
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
28
29
  lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
30
  lfss/src/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
30
- lfss/src/config.py,sha256=K1b5clNRO4EzxDG-p6U5aRLDaq-Up0tDHfn_D79D0ns,857
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=yFMoxHJ-ZRwi9qvSVdIl9m_gpycmL2eencdv7P0vzwQ,35678
33
- lfss/src/datatype.py,sha256=q2lc8BJaB2sS7gtqPMqxM625mckgI2SmvuqbadiObLY,2158
33
+ lfss/src/database.py,sha256=psNgY3QxHh5mmhqdpRwy3pucgZCi2d9kjUtdtjnB8P4,36042
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=OcyPuVyvBCxpgF8ESxzDYNZnUJIbkOGDv-eghUSVM9E,20063
37
+ lfss/src/server.py,sha256=IoqKpznDt28MIGUf79MIRFvlV_6VIaqGPmc_hkYcXuY,21150
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.0.dist-info/METADATA,sha256=uNZzNHCActK1ok2aPmZZqMXE3JxyWHStAVAH5mzpvkc,2076
41
- lfss-0.8.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
42
- lfss-0.8.0.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
43
- lfss-0.8.0.dist-info/RECORD,,
40
+ lfss/src/utils.py,sha256=DxjHabdiISMkrm1WQlpsZFKL3by6YrzBNQaDt_uZlRk,5744
41
+ lfss-0.8.2.dist-info/METADATA,sha256=gaqq7M4te2IAQk8OntQNXMaW3jbderKLu1C5pEA6ieU,2108
42
+ lfss-0.8.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
43
+ lfss-0.8.2.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
44
+ lfss-0.8.2.dist-info/RECORD,,
File without changes