lfss 0.3.2__py3-none-any.whl → 0.4.1__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
@@ -54,10 +54,16 @@ export default class Connector {
54
54
  * @param {File} file - the file to upload
55
55
  * @returns {Promise<string>} - the promise of the request, the url of the file
56
56
  */
57
- async put(path, file){
57
+ async put(path, file, {
58
+ overwrite = false,
59
+ permission = 0
60
+ } = {}){
58
61
  if (path.startsWith('/')){ path = path.slice(1); }
59
62
  const fileBytes = await file.arrayBuffer();
60
- const res = await fetch(this.config.endpoint + '/' + path, {
63
+ const dst = new URL(this.config.endpoint + '/' + path);
64
+ dst.searchParams.append('overwrite', overwrite);
65
+ dst.searchParams.append('permission', permission);
66
+ const res = await fetch(dst.toString(), {
61
67
  method: 'PUT',
62
68
  headers: {
63
69
  'Authorization': 'Bearer ' + this.config.token,
frontend/scripts.js CHANGED
@@ -172,7 +172,7 @@ Are you sure you want to proceed?
172
172
  async function uploadFile(...args){
173
173
  const [file, path] = args;
174
174
  try{
175
- await conn.put(path, file);
175
+ await conn.put(path, file, {overwrite: true});
176
176
  }
177
177
  catch (err){
178
178
  showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
lfss/cli/cli.py ADDED
@@ -0,0 +1,48 @@
1
+ from lfss.client import Connector, upload_directory
2
+ from lfss.src.database import FileReadPermission
3
+ from pathlib import Path
4
+ import argparse
5
+
6
+ def parse_arguments():
7
+ parser = argparse.ArgumentParser(description="Command line interface, please set LFSS_ENDPOINT and LFSS_TOKEN environment variables.")
8
+
9
+ sp = parser.add_subparsers(dest="command", required=True)
10
+
11
+ # upload
12
+ sp_upload = sp.add_parser("upload", help="Upload files")
13
+ sp_upload.add_argument("src", help="Source file or directory", type=str)
14
+ sp_upload.add_argument("dst", help="Destination path", type=str)
15
+ sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
16
+ sp_upload.add_argument("--interval", type=float, default=0, help="Interval between retries, only works with directory upload")
17
+ sp_upload.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
18
+ sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
19
+ sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
20
+
21
+ return parser.parse_args()
22
+
23
+ def main():
24
+ args = parse_arguments()
25
+ connector = Connector()
26
+ if args.command == "upload":
27
+ src_path = Path(args.src)
28
+ if src_path.is_dir():
29
+ upload_directory(
30
+ connector, args.src, args.dst,
31
+ verbose=True,
32
+ n_concurrent=args.jobs,
33
+ n_reties=args.retries,
34
+ interval=args.interval,
35
+ overwrite=args.overwrite,
36
+ permission=args.permission
37
+ )
38
+ else:
39
+ with open(args.src, 'rb') as f:
40
+ connector.put(
41
+ args.dst,
42
+ f.read(),
43
+ overwrite=args.overwrite,
44
+ permission=args.permission
45
+ )
46
+ else:
47
+ raise NotImplementedError(f"Command {args.command} not implemented.")
48
+
lfss/client/__init__.py CHANGED
@@ -0,0 +1,58 @@
1
+ import os, time
2
+ from threading import Lock
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from .api import Connector
5
+
6
+ def upload_directory(
7
+ connector: Connector,
8
+ directory: str,
9
+ path: str,
10
+ n_concurrent: int = 1,
11
+ n_reties: int = 0,
12
+ interval: float = 0,
13
+ verbose: bool = False,
14
+ **put_kwargs
15
+ ) -> list[str]:
16
+ assert path.endswith('/'), "Path must end with a slash."
17
+ if path.startswith('/'):
18
+ path = path[1:]
19
+
20
+ _counter = 0
21
+ _counter_lock = Lock()
22
+
23
+ faild_files = []
24
+ def put_file(file_path):
25
+ with _counter_lock:
26
+ nonlocal _counter
27
+ _counter += 1
28
+ this_count = _counter
29
+ dst_path = f"{path}{os.path.relpath(file_path, directory)}"
30
+ if verbose:
31
+ print(f"[{this_count}] Uploading {file_path} to {dst_path}")
32
+
33
+ this_try = 0
34
+ with open(file_path, 'rb') as f:
35
+ blob = f.read()
36
+
37
+ while this_try <= n_reties:
38
+ try:
39
+ connector.put(dst_path, blob, **put_kwargs)
40
+ break
41
+ except Exception as e:
42
+ if verbose:
43
+ print(f"[{this_count}] Error uploading {file_path}: {e}, retrying...")
44
+ this_try += 1
45
+ finally:
46
+ time.sleep(interval)
47
+
48
+ if this_try > n_reties:
49
+ faild_files.append(file_path)
50
+ if verbose:
51
+ print(f"[{this_count}] Failed to upload {file_path} after {n_reties} retries.")
52
+
53
+ with ThreadPoolExecutor(n_concurrent) as executor:
54
+ for root, dirs, files in os.walk(directory):
55
+ for file in files:
56
+ executor.submit(put_file, os.path.join(root, file))
57
+
58
+ return faild_files
lfss/client/api.py CHANGED
@@ -34,9 +34,14 @@ class Connector:
34
34
  return response
35
35
  return f
36
36
 
37
- def put(self, path: str, file_data: bytes):
37
+ def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, overwrite: bool = False):
38
38
  """Uploads a file to the specified path."""
39
- response = self._fetch('PUT', path)(
39
+ if path.startswith('/'):
40
+ path = path[1:]
41
+ response = self._fetch('PUT', path, search_params={
42
+ 'permission': int(permission),
43
+ 'overwrite': overwrite
44
+ })(
40
45
  data=file_data,
41
46
  headers={'Content-Type': 'application/octet-stream'}
42
47
  )
lfss/src/database.py CHANGED
@@ -69,7 +69,7 @@ class UserRecord:
69
69
  permission: 'FileReadPermission'
70
70
 
71
71
  def __str__(self):
72
- return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}), storage={self.max_storage}, permission={self.permission}"
72
+ return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}, storage={self.max_storage}, permission={self.permission})"
73
73
 
74
74
  DECOY_USER = UserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
75
75
  class UserConn(DBConnBase):
@@ -421,7 +421,7 @@ class FileConn(DBConnBase):
421
421
  new_exists = await self.get_file_record(new_url)
422
422
  if new_exists is not None:
423
423
  raise FileExistsError(f"File {new_url} already exists")
424
- async with self.conn.execute("UPDATE fmeta SET url = ? WHERE url = ?", (new_url, old_url)):
424
+ async with self.conn.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url)):
425
425
  self.logger.info(f"Moved file {old_url} to {new_url}")
426
426
 
427
427
  async def log_access(self, url: str):
@@ -481,8 +481,9 @@ class FileConn(DBConnBase):
481
481
  await self.conn.execute("DELETE FROM fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
482
482
 
483
483
  def validate_url(url: str, is_file = True):
484
- ret = not url.startswith('/') and not ('..' in url) and ('/' in url) and not ('//' in url) \
485
- and not ' ' in url and not url.startswith('\\') and not url.startswith('_') and not url.startswith('.')
484
+ prohibited_chars = ['..', ';', "'", '"', '\\', '\0', '\n', '\r', '\t', '\x0b', '\x0c']
485
+ ret = not url.startswith('/') and not url.startswith('_') and not url.startswith('.')
486
+ ret = ret and not any([c in url for c in prohibited_chars])
486
487
 
487
488
  if not ret:
488
489
  raise InvalidPathError(f"Invalid URL: {url}")
@@ -542,7 +543,7 @@ class Database:
542
543
  if _g_conn is not None:
543
544
  await _g_conn.rollback()
544
545
 
545
- async def save_file(self, u: int | str, url: str, blob: bytes):
546
+ async def save_file(self, u: int | str, url: str, blob: bytes, permission: FileReadPermission = FileReadPermission.UNSET):
546
547
  validate_url(url)
547
548
  assert isinstance(blob, bytes), "blob must be bytes"
548
549
 
@@ -570,7 +571,7 @@ class Database:
570
571
  f_id = uuid.uuid4().hex
571
572
  async with transaction(self):
572
573
  await self.file.set_file_blob(f_id, blob)
573
- await self.file.set_file_record(url, owner_id=user.id, file_id=f_id, file_size=file_size)
574
+ await self.file.set_file_record(url, owner_id=user.id, file_id=f_id, file_size=file_size, permission=permission)
574
575
  await self.user.set_active(user.username)
575
576
 
576
577
  # async def read_file_stream(self, url: str): ...
lfss/src/server.py CHANGED
@@ -15,7 +15,7 @@ from .error import *
15
15
  from .log import get_logger
16
16
  from .stat import RequestDB
17
17
  from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES
18
- from .utils import ensure_uri_compnents
18
+ from .utils import ensure_uri_compnents, format_last_modified, now_stamp
19
19
  from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission
20
20
 
21
21
  logger = get_logger("server", term_level="DEBUG")
@@ -81,6 +81,7 @@ app.add_middleware(
81
81
  @app.middleware("http")
82
82
  async def log_requests(request: Request, call_next):
83
83
 
84
+ request_time_stamp = now_stamp()
84
85
  start_time = time.perf_counter()
85
86
  response: Response = await call_next(request)
86
87
  end_time = time.perf_counter()
@@ -91,6 +92,7 @@ async def log_requests(request: Request, call_next):
91
92
  logger_failed_request.error(f"{request.method} {request.url.path} {response.status_code}")
92
93
 
93
94
  await req_conn.log_request(
95
+ request_time_stamp,
94
96
  request.method, request.url.path, response.status_code, response_time,
95
97
  headers = dict(request.headers),
96
98
  query = dict(request.query_params),
@@ -148,7 +150,8 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
148
150
  return Response(
149
151
  content=fblob, media_type=media_type, headers={
150
152
  "Content-Disposition": f"{disposition}; filename={fname}",
151
- "Content-Length": str(len(fblob))
153
+ "Content-Length": str(len(fblob)),
154
+ "Last-Modified": format_last_modified(file_record.create_time)
152
155
  }
153
156
  )
154
157
 
@@ -159,7 +162,12 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
159
162
 
160
163
  @router_fs.put("/{path:path}")
161
164
  @handle_exception
162
- async def put_file(request: Request, path: str, user: UserRecord = Depends(get_current_user)):
165
+ async def put_file(
166
+ request: Request,
167
+ path: str,
168
+ overwrite: Optional[bool] = False,
169
+ permission: int = 0,
170
+ user: UserRecord = Depends(get_current_user)):
163
171
  path = ensure_uri_compnents(path)
164
172
  if user.id == 0:
165
173
  logger.debug("Reject put request from DECOY_USER")
@@ -179,8 +187,10 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
179
187
  exists_flag = False
180
188
  file_record = await conn.file.get_file_record(path)
181
189
  if file_record:
182
- exists_flag = True
190
+ if not overwrite:
191
+ raise HTTPException(status_code=409, detail="File exists")
183
192
  # remove the old file
193
+ exists_flag = True
184
194
  await conn.delete_file(path)
185
195
 
186
196
  # check content-type
@@ -188,20 +198,20 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
188
198
  logger.debug(f"Content-Type: {content_type}")
189
199
  if content_type == "application/json":
190
200
  body = await request.json()
191
- await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'))
201
+ await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'), permission = FileReadPermission(permission))
192
202
  elif content_type == "application/x-www-form-urlencoded":
193
203
  # may not work...
194
204
  body = await request.form()
195
205
  file = body.get("file")
196
206
  if isinstance(file, str) or file is None:
197
207
  raise HTTPException(status_code=400, detail="Invalid form data, file required")
198
- await conn.save_file(user.id, path, await file.read())
208
+ await conn.save_file(user.id, path, await file.read(), permission = FileReadPermission(permission))
199
209
  elif content_type == "application/octet-stream":
200
210
  body = await request.body()
201
- await conn.save_file(user.id, path, body)
211
+ await conn.save_file(user.id, path, body, permission = FileReadPermission(permission))
202
212
  else:
203
213
  body = await request.body()
204
- await conn.save_file(user.id, path, body)
214
+ await conn.save_file(user.id, path, body, permission = FileReadPermission(permission))
205
215
 
206
216
  # https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
207
217
  if exists_flag:
lfss/src/stat.py CHANGED
@@ -13,7 +13,7 @@ class RequestDB:
13
13
  await self.conn.execute('''
14
14
  CREATE TABLE IF NOT EXISTS requests (
15
15
  id INTEGER PRIMARY KEY AUTOINCREMENT,
16
- time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
16
+ time FLOAT DEFAULT (strftime('%s', 'now')),
17
17
  method TEXT,
18
18
  path TEXT,
19
19
  headers TEXT,
@@ -43,7 +43,8 @@ class RequestDB:
43
43
  await self.commit()
44
44
 
45
45
  async def log_request(
46
- self, method: str, path: str,
46
+ self, time: float,
47
+ method: str, path: str,
47
48
  status: int, duration: float,
48
49
  headers: Optional[Any] = None,
49
50
  query: Optional[Any] = None,
@@ -57,9 +58,9 @@ class RequestDB:
57
58
  client = str(client)
58
59
  async with self.conn.execute('''
59
60
  INSERT INTO requests (
60
- method, path, headers, query, client, duration, request_size, response_size, status
61
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
62
- ''', (method, path, headers, query, client, duration, request_size, response_size, status)) as cursor:
61
+ time, method, path, headers, query, client, duration, request_size, response_size, status
62
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63
+ ''', (time, method, path, headers, query, client, duration, request_size, response_size, status)) as cursor:
63
64
  assert cursor.lastrowid is not None
64
65
  return cursor.lastrowid
65
66
 
lfss/src/utils.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import Callable
1
+ import datetime
2
2
  import urllib.parse
3
3
  import asyncio
4
4
  import functools
@@ -49,4 +49,20 @@ def debounce_async(delay: float = 0):
49
49
  except asyncio.CancelledError:
50
50
  pass
51
51
  return wrapper
52
- return debounce_wrap
52
+ return debounce_wrap
53
+
54
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
55
+ def format_last_modified(last_modified_gmt: str):
56
+ """
57
+ Format the last modified time to the HTTP standard format
58
+ - last_modified_gmt: The last modified time in SQLite ISO 8601 GMT format: e.g. '2021-09-01 12:00:00'
59
+ """
60
+ assert len(last_modified_gmt) == 19
61
+ dt = datetime.datetime.strptime(last_modified_gmt, '%Y-%m-%d %H:%M:%S')
62
+ return dt.strftime('%a, %d %b %Y %H:%M:%S GMT')
63
+
64
+ def now_stamp() -> float:
65
+ return datetime.datetime.now().timestamp()
66
+
67
+ def stamp_to_str(stamp: float) -> str:
68
+ return datetime.datetime.fromtimestamp(stamp).strftime('%Y-%m-%d %H:%M:%S')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.3.2
3
+ Version: 0.4.1
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -1,27 +1,28 @@
1
1
  Readme.md,sha256=8ZAADV1K20gEDhwiL-Qq_rBAePlwWQn8c5uJ_X7OCIg,1128
2
2
  docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
3
  docs/Permission.md,sha256=EY1Y4tT5PDdHDb2pXsVgMAJXxkUihTZfPT0_z7Q53FA,1485
4
- frontend/api.js,sha256=D_MaQlmBGzzSR30a23Kh3DxPeF8MpKYe5uXSb9srRTU,7281
4
+ frontend/api.js,sha256=vt6r9X0sWZTQwS7DEpysX7QocuXO7HQgZb98NwHknp8,7506
5
5
  frontend/index.html,sha256=VPJDs2LG8ep9kjlsKzjWzpN9vc1VGgdvOUlNTZWyQoQ,2088
6
6
  frontend/popup.css,sha256=VzkjG1ZTLxhHMtTyobnlvqYmVsTmdbJJed2Pu1cc06c,1007
7
7
  frontend/popup.js,sha256=dH5n7C2Vo9gCsMfQ4ajL4D1ETl0Wk9rIldxUb7x0f_c,4634
8
- frontend/scripts.js,sha256=1Cd5cb84SWolozA0Q1MpWaXiAqeQ5R6qZyk7gdoRPgU,18266
8
+ frontend/scripts.js,sha256=xx7jybD8kAwuHheC4zDGAaKk0RgQ4g29SfbLamKHdC8,18285
9
9
  frontend/styles.css,sha256=Ql_-W5dbh6LlJL2Kez8qsqPSF2fckFgmOYP4SMfKJVs,4065
10
10
  frontend/utils.js,sha256=biE2te5ezswZyuwlDTYbHEt7VWKfCcUrusNt1lHjkLw,2263
11
+ lfss/cli/cli.py,sha256=DtxkJ8u96zVmn2pmsIy_jP1-LClyrLLUEVle4y2ZPA0,2052
11
12
  lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
12
13
  lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
13
14
  lfss/cli/user.py,sha256=906MIQO1mr4XXzPpT8Kfri1G61bWlgjDzIglxypQ7z0,3251
14
- lfss/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- lfss/client/api.py,sha256=VBiU-JxGaN6S-fXYD_KvzG7FsGBIUzZ5IisJJSMooDk,3443
15
+ lfss/client/__init__.py,sha256=mkSP7GoeNJgJV9uOuenQpqQymKukzmvtSsrAG57ZS58,1746
16
+ lfss/client/api.py,sha256=i8idyRzw_qDZ13CrX_9vXOGuaX9nE870blmqac6sntU,3680
16
17
  lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
18
  lfss/src/config.py,sha256=mc8QoiWoPgiZ5JnSvKBH8jWbp5uLSyG3l5boDiQPXS0,325
18
- lfss/src/database.py,sha256=jsDcDrc5r4mnuFiY10YEMwIRMvx6c4PY4LN9C9BinDA,27854
19
+ lfss/src/database.py,sha256=9HRz5ejAPj0_lIjdBoLMkBG45D2cGA2TchXFUngdTE0,28005
19
20
  lfss/src/error.py,sha256=S5ui3tJ0uKX4EZnt2Db-KbDmJXlECI_OY95cNMkuegc,218
20
21
  lfss/src/log.py,sha256=qNE04sHoZ2rMbQ5dR2zT7Xaz1KVAfYp5hKpWJX42S9g,5244
21
- lfss/src/server.py,sha256=6sXOXGPF3s1h38zNS6mP4N9qEXZ8biEAx-wSdpCNlwg,13010
22
- lfss/src/stat.py,sha256=Y28h-TNhQJzsAlCP4E7mnJmgISXGIECvCCRPq_73ZN0,2043
23
- lfss/src/utils.py,sha256=qCXFTIcfnVsdg6zvoEDhZniLTU-WiG23SILXzUG1XBw,1613
24
- lfss-0.3.2.dist-info/METADATA,sha256=qHYqmRzikIGgohOabShmfj1T9lQHa6iIPAF2BV2wSVo,1787
25
- lfss-0.3.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
26
- lfss-0.3.2.dist-info/entry_points.txt,sha256=nUIJhenyZbcymvPVhrzV7SAtRhd7O52DflbRrpQUC04,110
27
- lfss-0.3.2.dist-info/RECORD,,
22
+ lfss/src/server.py,sha256=lfLVQlOFIHINo3wIB3VZ_yiLsiPGoPCu5R5miDan0Q8,13546
23
+ lfss/src/stat.py,sha256=_4OaSvBm7D6mPgifwxnhGIEk1_q3SxfJr3lizaEoV_w,2081
24
+ lfss/src/utils.py,sha256=8VkrtpSmurbMiX7GyK-n7Grvzy3uwSJXHdONEsuLCDI,2272
25
+ lfss-0.4.1.dist-info/METADATA,sha256=H8hmvbd3bJYy6gLbrDV27eDXoZBI42FNz3RD4h1ixiw,1787
26
+ lfss-0.4.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
27
+ lfss-0.4.1.dist-info/entry_points.txt,sha256=_FXOyxodFtVBaxtBhdHcupqW_IolIIx-S6y6p7CkDFk,137
28
+ lfss-0.4.1.dist-info/RECORD,,
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ lfss-cli=lfss.cli.cli:main
2
3
  lfss-panel=lfss.cli.panel:main
3
4
  lfss-serve=lfss.cli.serve:main
4
5
  lfss-user=lfss.cli.user:main
File without changes