lfss 0.7.15__py3-none-any.whl → 0.8.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.
lfss/src/server.py CHANGED
@@ -1,7 +1,7 @@
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
@@ -9,16 +9,19 @@ from fastapi.middleware.cors import CORSMiddleware
9
9
  import mimesniff
10
10
 
11
11
  import asyncio, json, time
12
- import mimetypes
13
12
  from contextlib import asynccontextmanager
14
13
 
15
14
  from .error import *
16
15
  from .log import get_logger
17
16
  from .stat import RequestDB
18
- from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_FILE_BYTES, CHUNK_SIZE
19
- from .utils import ensure_uri_compnents, format_last_modified, now_stamp
17
+ from .config import MAX_BUNDLE_BYTES, MAX_MEM_FILE_BYTES, LARGE_FILE_BYTES, CHUNK_SIZE
18
+ from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
20
19
  from .connection_pool import global_connection_init, global_connection_close, unique_cursor
21
- from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission, UserConn, FileConn, PathContents
20
+ from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn, delayed_log_activity, get_user
21
+ from .datatype import (
22
+ FileReadPermission, FileRecord, UserRecord, PathContents,
23
+ FileSortKey, DirSortKey
24
+ )
22
25
  from .thumb import get_thumb
23
26
 
24
27
  logger = get_logger("server", term_level="DEBUG")
@@ -35,6 +38,7 @@ async def lifespan(app: FastAPI):
35
38
  yield
36
39
  await req_conn.commit()
37
40
  finally:
41
+ await wait_for_debounce_tasks()
38
42
  await asyncio.gather(req_conn.close(), global_connection_close())
39
43
 
40
44
  def handle_exception(fn):
@@ -49,6 +53,7 @@ def handle_exception(fn):
49
53
  if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
50
54
  if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
51
55
  if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
56
+ if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
52
57
  logger.error(f"Uncaptured error in {fn.__name__}: {e}")
53
58
  raise
54
59
  return wrapper
@@ -182,7 +187,7 @@ async def emit_file(
182
187
  @handle_exception
183
188
  async def get_file(
184
189
  path: str,
185
- download: bool = False, flat: bool = False, thumb: bool = False,
190
+ download: bool = False, thumb: bool = False,
186
191
  user: UserRecord = Depends(get_current_user)
187
192
  ):
188
193
  path = ensure_uri_compnents(path)
@@ -199,8 +204,6 @@ async def get_file(
199
204
  return await emit_thumbnail(path, download, create_time=None)
200
205
 
201
206
  if path == "/":
202
- if flat:
203
- raise HTTPException(status_code=400, detail="Flat query not supported for root path")
204
207
  return PathContents(
205
208
  dirs = await fconn.list_root_dirs(user.username, skim=True) \
206
209
  if not user.is_admin else await fconn.list_root_dirs(skim=True),
@@ -210,7 +213,7 @@ async def get_file(
210
213
  if not path.startswith(f"{user.username}/") and not user.is_admin:
211
214
  raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
212
215
 
213
- return await fconn.list_path(path, flat = flat)
216
+ return await fconn.list_path(path)
214
217
 
215
218
  # handle file query
216
219
  async with unique_cursor() as conn:
@@ -242,18 +245,21 @@ async def put_file(
242
245
  path: str,
243
246
  conflict: Literal["overwrite", "skip", "abort"] = "abort",
244
247
  permission: int = 0,
245
- user: UserRecord = Depends(registered_user)):
248
+ user: UserRecord = Depends(registered_user)
249
+ ):
246
250
  path = ensure_uri_compnents(path)
247
- if not path.startswith(f"{user.username}/") and not user.is_admin:
248
- logger.debug(f"Reject put request from {user.username} to {path}")
249
- raise HTTPException(status_code=403, detail="Permission denied")
250
-
251
- content_length = request.headers.get("Content-Length")
252
- if content_length is not None:
253
- content_length = int(content_length)
254
- if content_length > MAX_FILE_BYTES:
255
- logger.debug(f"Reject put request from {user.username} to {path}, file too large")
256
- raise HTTPException(status_code=413, detail="File too large")
251
+ assert not path.endswith("/"), "Path must not end with /"
252
+ if not path.startswith(f"{user.username}/"):
253
+ if not user.is_admin:
254
+ logger.debug(f"Reject put request from {user.username} to {path}")
255
+ raise HTTPException(status_code=403, detail="Permission denied")
256
+ else:
257
+ first_comp = path.split("/")[0]
258
+ async with unique_cursor() as c:
259
+ uconn = UserConn(c)
260
+ owner = await uconn.get_user(first_comp)
261
+ if not owner:
262
+ raise HTTPException(status_code=404, detail="Owner not found")
257
263
 
258
264
  logger.info(f"PUT {path}, user: {user.username}")
259
265
  exists_flag = False
@@ -276,47 +282,73 @@ async def put_file(
276
282
  # check content-type
277
283
  content_type = request.headers.get("Content-Type")
278
284
  logger.debug(f"Content-Type: {content_type}")
279
- if content_type == "application/json":
280
- body = await request.json()
281
- blobs = json.dumps(body).encode('utf-8')
282
- elif content_type == "application/x-www-form-urlencoded":
283
- # may not work...
284
- body = await request.form()
285
- file = body.get("file")
286
- if isinstance(file, str) or file is None:
287
- raise HTTPException(status_code=400, detail="Invalid form data, file required")
288
- blobs = await file.read()
289
- elif content_type == "application/octet-stream":
290
- blobs = await request.body()
291
- else:
292
- blobs = await request.body()
285
+ if not (content_type == "application/octet-stream" or content_type == "application/json"):
286
+ raise HTTPException(status_code=415, detail="Unsupported content type, put request must be application/json or application/octet-stream")
293
287
 
294
- # check file type
295
- assert not path.endswith("/"), "Path must be a file"
296
- fname = path.split("/")[-1]
297
- mime_t, _ = mimetypes.guess_type(fname)
298
- if mime_t is None:
299
- mime_t = mimesniff.what(blobs)
300
- if mime_t is None:
301
- mime_t = "application/octet-stream"
302
-
303
- if len(blobs) > LARGE_FILE_BYTES:
304
- async def blob_reader():
305
- for b in range(0, len(blobs), CHUNK_SIZE):
306
- yield blobs[b:b+CHUNK_SIZE]
307
- await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission), mime_type = mime_t)
308
- else:
309
- await db.save_file(user.id, path, blobs, permission = FileReadPermission(permission), mime_type=mime_t)
288
+ async def blob_reader():
289
+ nonlocal request
290
+ async for chunk in request.stream():
291
+ yield chunk
292
+
293
+ await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
310
294
 
311
295
  # https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
312
- if exists_flag:
313
- return Response(status_code=201, headers={
314
- "Content-Type": "application/json",
315
- }, content=json.dumps({"url": path}))
316
- else:
317
- return Response(status_code=200, headers={
318
- "Content-Type": "application/json",
319
- }, content=json.dumps({"url": path}))
296
+ return Response(status_code=200 if exists_flag else 201, headers={
297
+ "Content-Type": "application/json",
298
+ }, content=json.dumps({"url": path}))
299
+
300
+ # using form-data instead of raw body
301
+ @router_fs.post("/{path:path}")
302
+ @handle_exception
303
+ async def post_file(
304
+ path: str,
305
+ file: UploadFile,
306
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
307
+ permission: int = 0,
308
+ user: UserRecord = Depends(registered_user)
309
+ ):
310
+ path = ensure_uri_compnents(path)
311
+ assert not path.endswith("/"), "Path must not end with /"
312
+ if not path.startswith(f"{user.username}/"):
313
+ if not user.is_admin:
314
+ logger.debug(f"Reject put request from {user.username} to {path}")
315
+ raise HTTPException(status_code=403, detail="Permission denied")
316
+ else:
317
+ first_comp = path.split("/")[0]
318
+ async with unique_cursor() as conn:
319
+ uconn = UserConn(conn)
320
+ owner = await uconn.get_user(first_comp)
321
+ if not owner:
322
+ raise HTTPException(status_code=404, detail="Owner not found")
323
+
324
+ logger.info(f"POST {path}, user: {user.username}")
325
+ exists_flag = False
326
+ async with unique_cursor() as conn:
327
+ fconn = FileConn(conn)
328
+ file_record = await fconn.get_file_record(path)
329
+
330
+ if file_record:
331
+ if conflict == "abort":
332
+ raise HTTPException(status_code=409, detail="File exists")
333
+ if conflict == "skip":
334
+ return Response(status_code=200, headers={
335
+ "Content-Type": "application/json",
336
+ }, content=json.dumps({"url": path}))
337
+ exists_flag = True
338
+ if not user.is_admin and not file_record.owner_id == user.id:
339
+ raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
340
+ await db.delete_file(path)
341
+
342
+ async def blob_reader():
343
+ nonlocal file
344
+ while (chunk := await file.read(CHUNK_SIZE)):
345
+ yield chunk
346
+
347
+ await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
348
+ return Response(status_code=200 if exists_flag else 201, headers={
349
+ "Content-Type": "application/json",
350
+ }, content=json.dumps({"url": path}))
351
+
320
352
 
321
353
  @router_fs.delete("/{path:path}")
322
354
  @handle_exception
@@ -332,7 +364,7 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
332
364
  else:
333
365
  res = await db.delete_file(path, user if not user.is_admin else None)
334
366
 
335
- await db.record_user_activity(user.username)
367
+ await delayed_log_activity(user.username)
336
368
  if res:
337
369
  return Response(status_code=200, content="Deleted")
338
370
  else:
@@ -366,7 +398,10 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
366
398
 
367
399
  async with unique_cursor() as conn:
368
400
  fconn = FileConn(conn)
369
- files = (await fconn.list_path(path, flat = True)).files
401
+ files = await fconn.list_path_files(
402
+ url = path, flat = True,
403
+ limit=(await fconn.count_path_files(url = path, flat = True))
404
+ )
370
405
  files = [f for f in files if await is_access_granted(f)]
371
406
  if len(files) == 0:
372
407
  raise HTTPException(status_code=404, detail="No files found")
@@ -421,7 +456,7 @@ async def update_file_meta(
421
456
  path = ensure_uri_compnents(path)
422
457
  if path.startswith("/"):
423
458
  path = path[1:]
424
- await db.record_user_activity(user.username)
459
+ await delayed_log_activity(user.username)
425
460
 
426
461
  # file
427
462
  if not path.endswith("/"):
@@ -448,6 +483,57 @@ async def update_file_meta(
448
483
  await db.move_path(user, path, new_path)
449
484
 
450
485
  return Response(status_code=200, content="OK")
486
+
487
+ async def validate_path_permission(path: str, user: UserRecord):
488
+ if not path.endswith("/"):
489
+ raise HTTPException(status_code=400, detail="Path must end with /")
490
+ if not path.startswith(f"{user.username}/") and not user.is_admin:
491
+ raise HTTPException(status_code=403, detail="Permission denied")
492
+
493
+ @router_api.get("/count-files")
494
+ async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
495
+ await validate_path_permission(path, user)
496
+ path = ensure_uri_compnents(path)
497
+ async with unique_cursor() as conn:
498
+ fconn = FileConn(conn)
499
+ return { "count": await fconn.count_path_files(url = path, flat = flat) }
500
+ @router_api.get("/list-files")
501
+ async def list_files(
502
+ path: str, offset: int = 0, limit: int = 1000,
503
+ order_by: FileSortKey = "", order_desc: bool = False,
504
+ flat: bool = False, user: UserRecord = Depends(registered_user)
505
+ ):
506
+ await validate_path_permission(path, user)
507
+ path = ensure_uri_compnents(path)
508
+ async with unique_cursor() as conn:
509
+ fconn = FileConn(conn)
510
+ return await fconn.list_path_files(
511
+ url = path, offset = offset, limit = limit,
512
+ order_by=order_by, order_desc=order_desc,
513
+ flat=flat
514
+ )
515
+
516
+ @router_api.get("/count-dirs")
517
+ async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
518
+ await validate_path_permission(path, user)
519
+ path = ensure_uri_compnents(path)
520
+ async with unique_cursor() as conn:
521
+ fconn = FileConn(conn)
522
+ return { "count": await fconn.count_path_dirs(url = path) }
523
+ @router_api.get("/list-dirs")
524
+ async def list_dirs(
525
+ path: str, offset: int = 0, limit: int = 1000,
526
+ order_by: DirSortKey = "", order_desc: bool = False,
527
+ skim: bool = True, user: UserRecord = Depends(registered_user)
528
+ ):
529
+ await validate_path_permission(path, user)
530
+ path = ensure_uri_compnents(path)
531
+ async with unique_cursor() as conn:
532
+ fconn = FileConn(conn)
533
+ return await fconn.list_path_dirs(
534
+ url = path, offset = offset, limit = limit,
535
+ order_by=order_by, order_desc=order_desc, skim=skim
536
+ )
451
537
 
452
538
  @router_api.get("/whoami")
453
539
  @handle_exception
lfss/src/stat.py CHANGED
@@ -42,7 +42,7 @@ class RequestDB:
42
42
  async def commit(self):
43
43
  await self.conn.commit()
44
44
 
45
- @debounce_async(0.05)
45
+ @debounce_async()
46
46
  async def ensure_commit_once(self):
47
47
  await self.commit()
48
48
 
lfss/src/utils.py CHANGED
@@ -1,11 +1,14 @@
1
- import datetime
1
+ import datetime, time
2
2
  import urllib.parse
3
3
  import asyncio
4
4
  import functools
5
5
  import hashlib
6
+ from asyncio import Lock
7
+ from collections import OrderedDict
6
8
  from concurrent.futures import ThreadPoolExecutor
7
9
  from typing import TypeVar, Callable, Awaitable
8
10
  from functools import wraps, partial
11
+ from uuid import uuid4
9
12
  import os
10
13
 
11
14
  def hash_credential(username: str, password: str):
@@ -25,25 +28,56 @@ def ensure_uri_compnents(path: str):
25
28
  """ Ensure the path components are safe to use """
26
29
  return encode_uri_compnents(decode_uri_compnents(path))
27
30
 
28
- def debounce_async(delay: float = 0):
29
- """ Debounce the async procedure """
31
+ g_debounce_tasks: OrderedDict[str, asyncio.Task] = OrderedDict()
32
+ lock_debounce_task_queue = Lock()
33
+ async def wait_for_debounce_tasks():
34
+ async def stop_task(task: asyncio.Task):
35
+ task.cancel()
36
+ try:
37
+ await task
38
+ except asyncio.CancelledError:
39
+ pass
40
+ await asyncio.gather(*map(stop_task, g_debounce_tasks.values()))
41
+ g_debounce_tasks.clear()
42
+
43
+ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
44
+ """
45
+ Debounce the async procedure,
46
+ ensuring execution at least once every `max_wait` seconds.
47
+ """
30
48
  def debounce_wrap(func):
31
- # https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancel
49
+ task_record: tuple[str, asyncio.Task] | None = None
50
+ last_execution_time = 0
51
+
32
52
  async def delayed_func(*args, **kwargs):
53
+ nonlocal last_execution_time
33
54
  await asyncio.sleep(delay)
34
55
  await func(*args, **kwargs)
56
+ last_execution_time = time.monotonic()
35
57
 
36
- task_record: asyncio.Task | None = None
37
58
  @functools.wraps(func)
38
59
  async def wrapper(*args, **kwargs):
39
- nonlocal task_record
40
- if task_record is not None:
41
- task_record.cancel()
42
- task_record = asyncio.create_task(delayed_func(*args, **kwargs))
43
- try:
44
- await task_record
45
- except asyncio.CancelledError:
46
- pass
60
+ nonlocal task_record, last_execution_time
61
+
62
+ async with lock_debounce_task_queue:
63
+ if task_record is not None:
64
+ task_record[1].cancel()
65
+ g_debounce_tasks.pop(task_record[0], None)
66
+
67
+ if time.monotonic() - last_execution_time > max_wait:
68
+ await func(*args, **kwargs)
69
+ last_execution_time = time.monotonic()
70
+ return
71
+
72
+ task = asyncio.create_task(delayed_func(*args, **kwargs))
73
+ task_uid = uuid4().hex
74
+ task_record = (task_uid, task)
75
+ async with lock_debounce_task_queue:
76
+ g_debounce_tasks[task_uid] = task
77
+ if len(g_debounce_tasks) > 2048:
78
+ # finished tasks are not removed from the dict
79
+ # so we need to clear it periodically
80
+ await wait_for_debounce_tasks()
47
81
  return wrapper
48
82
  return debounce_wrap
49
83
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.7.15
3
+ Version: 0.8.1
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -16,6 +16,8 @@ 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
20
+ Requires-Dist: requests (==2.*)
19
21
  Requires-Dist: uvicorn (==0.*)
20
22
  Project-URL: Repository, https://github.com/MenxLi/lfss
21
23
  Description-Content-Type: text/markdown
@@ -23,7 +25,7 @@ Description-Content-Type: text/markdown
23
25
  # Lightweight File Storage Service (LFSS)
24
26
  [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
25
27
 
26
- My experiment on a lightweight file/object storage service.
28
+ My experiment on a lightweight and high-performance file/object storage service.
27
29
  It stores small files and metadata in sqlite, large files in the filesystem.
28
30
  Tested on 2 million files, and it works fine...
29
31
 
@@ -45,7 +47,7 @@ Or, you can start a web server at `/frontend` and open `index.html` in your brow
45
47
 
46
48
  The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
47
49
  Authentication is done via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
48
- You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss/client/api.py` for the API usage.
50
+ You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss/api/connector.py` for the API usage.
49
51
 
50
52
  By default, the service exposes all files to the public for `GET` requests,
51
53
  but file-listing is restricted to the user's own files.
@@ -0,0 +1,43 @@
1
+ Readme.md,sha256=LpbTvUWjCOv4keMNDrZvEnNAmCQnvaxvlq2srWixXn0,1299
2
+ docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
+ docs/Permission.md,sha256=9r9nEmhqfz18RTS8FI0fZ9F0a31r86OoAyx3EQxxpk0,2317
4
+ frontend/api.js,sha256=wUJNAkL8QigAiwR_jaMPUhCQEsL-lp0wZ6XeueYgunE,18049
5
+ frontend/index.html,sha256=-k0bJ5FRqdl_H-O441D_H9E-iejgRCaL_z5UeYaS2qc,3384
6
+ frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
7
+ frontend/info.js,sha256=WhOGaeqMoezEAfg4nIpK26hvejC7AZ-ZDLiJmRj0kDk,5758
8
+ frontend/login.css,sha256=VMM0QfbDFYerxKWKSGhMI1yg5IRBXg0TTdLJEEhQZNk,355
9
+ frontend/login.js,sha256=QoO8yKmBHDVP-ZomCMOaV7xVUVIhpl7esJrb6T5aHQE,2466
10
+ frontend/popup.css,sha256=TJZYFW1ZcdD1IVTlNPYNtMWKPbN6XDbQ4hKBOFK8uLg,1284
11
+ frontend/popup.js,sha256=3PgaGZmxSdV1E-D_MWgcR7aHWkcsHA1BNKSOkmP66tA,5191
12
+ frontend/scripts.js,sha256=2YoMhrcAkI1bHihD_2EK6uCHZ1s0DiIR3FzZsh79x9A,21729
13
+ frontend/state.js,sha256=vbNL5DProRKmSEY7xu9mZH6IY0PBenF8WGxPtGgDnLI,1680
14
+ frontend/styles.css,sha256=xcNLqI3KBsY5TLnku8UIP0Jfr7QLajr1_KNlZj9eheM,4935
15
+ frontend/thumb.css,sha256=rNsx766amYS2DajSQNabhpQ92gdTpNoQKmV69OKvtpI,295
16
+ frontend/thumb.js,sha256=RQ_whXNwmkdG4SEbNQGeh488YYzqwoNYDc210hPeuhQ,5703
17
+ frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
18
+ lfss/api/__init__.py,sha256=MRzwISePOdq3of9IWGryVWX6coGkxeJ3OEh42Se4IYc,6029
19
+ lfss/api/connector.py,sha256=tmcgKswE0sktBhanEDEc6mpuJA0de7C-DS2YqlpxHX4,10743
20
+ lfss/cli/balance.py,sha256=R2rbO2tg9TVnnQIVeU0GJVeMS-5LDhEdk4mbOE9qGq0,4121
21
+ lfss/cli/cli.py,sha256=8VKe41m_LhVSFxGlvgBxdz55sjscLNbbkNX1fOnmES4,4618
22
+ lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
23
+ lfss/cli/serve.py,sha256=T-jz_PJcaY9FJRRfyWV9MbjDI73YdOOCn4Nr2PO-s0c,993
24
+ lfss/cli/user.py,sha256=wlR-xcJKCtr_y5QgYO9GM0JyDCKooIRlsAxw2eilPfs,3418
25
+ lfss/cli/vacuum.py,sha256=4TMMYC_5yEt7jeaFTVC3iX0X6aUzYXBiCsrcIYgw_uA,3695
26
+ lfss/sql/init.sql,sha256=C-JtQAlaOjESI8uoF1Y_9dKukEVSw5Ll-7yA3gG-XHU,1210
27
+ lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
28
+ lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
+ lfss/src/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
30
+ lfss/src/config.py,sha256=7k_6L_aG7vD-lVcmgpwMIg-ggpMMDNU8IbaB4y1effE,861
31
+ 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
34
+ lfss/src/error.py,sha256=Bh_GUtuNsMSMIKbFreU4CfZzL96TZdNAIcgmDxvGbQ0,333
35
+ lfss/src/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
36
+ lfss/src/server.py,sha256=mUu0WbiGdM08Jos7a4r_e9ND5sK_tNDEv1VGRPIHvLk,21206
37
+ lfss/src/stat.py,sha256=WlRiiAl0rHX9oPi3thi6K4GKn70WHpClSDCt7iZUow4,3197
38
+ 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,,
lfss/client/api.py DELETED
@@ -1,143 +0,0 @@
1
- from typing import Optional, Literal
2
- import os
3
- import requests
4
- import urllib.parse
5
- from lfss.src.datatype import (
6
- FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents
7
- )
8
- from lfss.src.utils import ensure_uri_compnents
9
-
10
- _default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
11
- _default_token = os.environ.get('LFSS_TOKEN', '')
12
-
13
- class Connector:
14
- def __init__(self, endpoint=_default_endpoint, token=_default_token):
15
- assert token, "No token provided. Please set LFSS_TOKEN environment variable."
16
- self.config = {
17
- "endpoint": endpoint,
18
- "token": token
19
- }
20
-
21
- def _fetch_factory(
22
- self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
23
- path: str, search_params: dict = {}
24
- ):
25
- if path.startswith('/'):
26
- path = path[1:]
27
- path = ensure_uri_compnents(path)
28
- def f(**kwargs):
29
- url = f"{self.config['endpoint']}/{path}" + "?" + urllib.parse.urlencode(search_params)
30
- headers: dict = kwargs.pop('headers', {})
31
- headers.update({
32
- 'Authorization': f"Bearer {self.config['token']}",
33
- })
34
- with requests.Session() as s:
35
- response = s.request(method, url, headers=headers, **kwargs)
36
- response.raise_for_status()
37
- return response
38
- return f
39
-
40
- def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
41
- """Uploads a file to the specified path."""
42
- assert isinstance(file_data, bytes), "file_data must be bytes"
43
-
44
- # Skip ahead by checking if the file already exists
45
- if conflict == 'skip-ahead':
46
- exists = self.get_metadata(path)
47
- if exists is None:
48
- conflict = 'skip'
49
- else:
50
- return {'status': 'skipped', 'path': path}
51
-
52
- response = self._fetch_factory('PUT', path, search_params={
53
- 'permission': int(permission),
54
- 'conflict': conflict
55
- })(
56
- data=file_data,
57
- headers={'Content-Type': 'application/octet-stream'}
58
- )
59
- return response.json()
60
-
61
- def put_json(self, path: str, data: dict, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
62
- """Uploads a JSON file to the specified path."""
63
- assert path.endswith('.json'), "Path must end with .json"
64
- assert isinstance(data, dict), "data must be a dict"
65
-
66
- # Skip ahead by checking if the file already exists
67
- if conflict == 'skip-ahead':
68
- exists = self.get_metadata(path)
69
- if exists is None:
70
- conflict = 'skip'
71
- else:
72
- return {'status': 'skipped', 'path': path}
73
-
74
- response = self._fetch_factory('PUT', path, search_params={
75
- 'permission': int(permission),
76
- 'conflict': conflict
77
- })(
78
- json=data,
79
- headers={'Content-Type': 'application/json'}
80
- )
81
- return response.json()
82
-
83
- def _get(self, path: str) -> Optional[requests.Response]:
84
- try:
85
- response = self._fetch_factory('GET', path)()
86
- except requests.exceptions.HTTPError as e:
87
- if e.response.status_code == 404:
88
- return None
89
- raise e
90
- return response
91
-
92
- def get(self, path: str) -> Optional[bytes]:
93
- """Downloads a file from the specified path."""
94
- response = self._get(path)
95
- if response is None: return None
96
- return response.content
97
-
98
- def get_json(self, path: str) -> Optional[dict]:
99
- response = self._get(path)
100
- if response is None: return None
101
- assert response.headers['Content-Type'] == 'application/json'
102
- return response.json()
103
-
104
- def delete(self, path: str):
105
- """Deletes the file at the specified path."""
106
- self._fetch_factory('DELETE', path)()
107
-
108
- def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
109
- """Gets the metadata for the file at the specified path."""
110
- try:
111
- response = self._fetch_factory('GET', '_api/meta', {'path': path})()
112
- if path.endswith('/'):
113
- return DirectoryRecord(**response.json())
114
- else:
115
- return FileRecord(**response.json())
116
- except requests.exceptions.HTTPError as e:
117
- if e.response.status_code == 404:
118
- return None
119
- raise e
120
-
121
- def list_path(self, path: str, flat: bool = False) -> PathContents:
122
- assert path.endswith('/')
123
- response = self._fetch_factory('GET', path, {'flat': flat})()
124
- dirs = [DirectoryRecord(**d) for d in response.json()['dirs']]
125
- files = [FileRecord(**f) for f in response.json()['files']]
126
- return PathContents(dirs=dirs, files=files)
127
-
128
- def set_file_permission(self, path: str, permission: int | FileReadPermission):
129
- """Sets the file permission for the specified path."""
130
- self._fetch_factory('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
131
- headers={'Content-Type': 'application/www-form-urlencoded'}
132
- )
133
-
134
- def move(self, path: str, new_path: str):
135
- """Move file or directory to a new path."""
136
- self._fetch_factory('POST', '_api/meta', {'path': path, 'new_path': new_path})(
137
- headers = {'Content-Type': 'application/www-form-urlencoded'}
138
- )
139
-
140
- def whoami(self) -> UserRecord:
141
- """Gets information about the current user."""
142
- response = self._fetch_factory('GET', '_api/whoami')()
143
- return UserRecord(**response.json())
@@ -1,41 +0,0 @@
1
- Readme.md,sha256=vsPotlwPAaHI5plh4aaszpi3rr7ZGDn7-wLdEYTWQ0k,1275
2
- docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
- docs/Permission.md,sha256=X0VNfBKU52f93QYqcVyiBFJ3yURiSkhIo9S_5fdSgzM,2265
4
- frontend/api.js,sha256=o1sP4rKxxnM-rebxnlMlPkhPHzKaVW4kZC7B4ufbOK4,8026
5
- frontend/index.html,sha256=i45ilkRCorqXP0bTfMiwT3QmmEF23V34lZJC1nODHLo,2862
6
- frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
7
- frontend/info.js,sha256=WhOGaeqMoezEAfg4nIpK26hvejC7AZ-ZDLiJmRj0kDk,5758
8
- frontend/popup.css,sha256=TJZYFW1ZcdD1IVTlNPYNtMWKPbN6XDbQ4hKBOFK8uLg,1284
9
- frontend/popup.js,sha256=3PgaGZmxSdV1E-D_MWgcR7aHWkcsHA1BNKSOkmP66tA,5191
10
- frontend/scripts.js,sha256=tmA_tG3bLEEqn0jTgZ-DkcEl6egxdrFYJmstb9eZMr0,21862
11
- frontend/state.js,sha256=Dda-2G4QzyqdxffjJa3Lb7rgJOrg2LvJ3TCRcB8YCrU,1327
12
- frontend/styles.css,sha256=krMo6Ulroi8pqEq1exQsFEU-FJqT9GzI8vyARiNF11k,4484
13
- frontend/thumb.css,sha256=1i8wudiMWGwdrnTqs6yrS8YaiPeHPR-A2YqUNJN20Ok,165
14
- frontend/thumb.js,sha256=6m8bscQpi2sYaLRir2ZeY1H_1ZRKFVss5P28AnLvIVQ,5486
15
- frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
16
- lfss/cli/balance.py,sha256=R2rbO2tg9TVnnQIVeU0GJVeMS-5LDhEdk4mbOE9qGq0,4121
17
- lfss/cli/cli.py,sha256=LH1nx5wI1K2DZ3hvHz7oq5HcXVDoW2V6sr7q9gJ8gqo,4621
18
- lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
19
- lfss/cli/serve.py,sha256=T-jz_PJcaY9FJRRfyWV9MbjDI73YdOOCn4Nr2PO-s0c,993
20
- lfss/cli/user.py,sha256=ETLtj0N-kmxv0mhmeAsO6cY7kPq7nOOP4DetxIRoQpQ,3405
21
- lfss/cli/vacuum.py,sha256=4TMMYC_5yEt7jeaFTVC3iX0X6aUzYXBiCsrcIYgw_uA,3695
22
- lfss/client/__init__.py,sha256=kcClDdHaQowAhHFYdrFyFqHIOe5MEjfENYq1icuj3Mg,4577
23
- lfss/client/api.py,sha256=aHorSuxl79xvRRfwuitXANYoTogWKVO11XX7mdrhHTE,5807
24
- lfss/sql/init.sql,sha256=C-JtQAlaOjESI8uoF1Y_9dKukEVSw5Ll-7yA3gG-XHU,1210
25
- lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
26
- lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
- lfss/src/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
28
- lfss/src/config.py,sha256=K1b5clNRO4EzxDG-p6U5aRLDaq-Up0tDHfn_D79D0ns,857
29
- lfss/src/connection_pool.py,sha256=r4Ho5d_Gd4S_KbT7515UJoiyfIgS6xyttqMsKqOfaIg,5190
30
- lfss/src/database.py,sha256=w2QPE3h1Lx0D0fUmdtu9s1XHpNp9p27zqm8AVeP2UVg,32476
31
- lfss/src/datatype.py,sha256=WfrLALU_7wei5-i_b0TxY8xWI5mwxLUHFepHSps49zA,1767
32
- lfss/src/error.py,sha256=imbhwnbhnI3HLhkbfICROe3F0gleKrOk4XnqHJDOtuI,285
33
- lfss/src/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
34
- lfss/src/server.py,sha256=igkPC3gdJoIqcVTKBAKkVPRrclXR2ZNBdRIAEci4xMo,17717
35
- lfss/src/stat.py,sha256=Wr-ug_JqtbSIf3XwQnv1xheGhsDTEOlLWuYoKO_26Jo,3201
36
- lfss/src/thumb.py,sha256=qjCNMpnCozMuzkhm-2uAYy1eAuYTeWG6xqs-13HX-7k,3266
37
- lfss/src/utils.py,sha256=TBGYvgt6xMP8UC5wTGHAr9fmdhu0_gjOtxcSeyvGyVM,3918
38
- lfss-0.7.15.dist-info/METADATA,sha256=s6jCttzD9bRauwKiKGNgrBouK5UDNEUMTgDHZBY_VfY,2021
39
- lfss-0.7.15.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
40
- lfss-0.7.15.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
41
- lfss-0.7.15.dist-info/RECORD,,
File without changes