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.
- Readme.md +2 -2
- docs/Permission.md +4 -2
- frontend/api.js +271 -8
- frontend/index.html +40 -28
- frontend/login.css +21 -0
- frontend/login.js +83 -0
- frontend/scripts.js +77 -88
- frontend/state.js +19 -4
- frontend/styles.css +26 -8
- frontend/thumb.css +6 -0
- frontend/thumb.js +6 -2
- lfss/{client → api}/__init__.py +72 -41
- lfss/api/connector.py +261 -0
- lfss/cli/cli.py +1 -1
- lfss/cli/user.py +1 -1
- lfss/src/config.py +1 -1
- lfss/src/connection_pool.py +3 -2
- lfss/src/database.py +193 -100
- lfss/src/datatype.py +8 -3
- lfss/src/error.py +3 -1
- lfss/src/server.py +147 -61
- lfss/src/stat.py +1 -1
- lfss/src/utils.py +47 -13
- {lfss-0.7.15.dist-info → lfss-0.8.1.dist-info}/METADATA +5 -3
- lfss-0.8.1.dist-info/RECORD +43 -0
- lfss/client/api.py +0 -143
- lfss-0.7.15.dist-info/RECORD +0 -41
- {lfss-0.7.15.dist-info → lfss-0.8.1.dist-info}/WHEEL +0 -0
- {lfss-0.7.15.dist-info → lfss-0.8.1.dist-info}/entry_points.txt +0 -0
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,
|
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,
|
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,
|
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
|
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
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
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
|
-
|
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
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
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
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
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
|
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 =
|
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
|
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
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
|
-
|
29
|
-
|
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
|
-
|
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
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
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.
|
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
|
[](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/
|
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())
|
lfss-0.7.15.dist-info/RECORD
DELETED
@@ -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
|