lfss 0.9.0__py3-none-any.whl → 0.9.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 DELETED
@@ -1,604 +0,0 @@
1
- from typing import Optional, Literal
2
- from functools import wraps
3
-
4
- from fastapi import FastAPI, APIRouter, Depends, Request, Response, UploadFile
5
- from fastapi.responses import StreamingResponse
6
- from fastapi.exceptions import HTTPException
7
- from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
8
- from fastapi.middleware.cors import CORSMiddleware
9
-
10
- import asyncio, json, time
11
- from contextlib import asynccontextmanager
12
-
13
- from .error import *
14
- from .log import get_logger
15
- from .stat import RequestDB
16
- from .config import MAX_BUNDLE_BYTES, CHUNK_SIZE
17
- from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
18
- from .connection_pool import global_connection_init, global_connection_close, unique_cursor
19
- from .database import Database, DECOY_USER, check_file_read_permission, check_path_permission, UserConn, FileConn
20
- from .database import delayed_log_activity, delayed_log_access
21
- from .datatype import (
22
- FileReadPermission, FileRecord, UserRecord, PathContents, AccessLevel,
23
- FileSortKey, DirSortKey
24
- )
25
- from .thumb import get_thumb
26
-
27
- logger = get_logger("server", term_level="DEBUG")
28
- logger_failed_request = get_logger("failed_requests", term_level="INFO")
29
- db = Database()
30
- req_conn = RequestDB()
31
-
32
- @asynccontextmanager
33
- async def lifespan(app: FastAPI):
34
- global db
35
- try:
36
- await global_connection_init(n_read = 2)
37
- await asyncio.gather(db.init(), req_conn.init())
38
- yield
39
- await req_conn.commit()
40
- finally:
41
- await wait_for_debounce_tasks()
42
- await asyncio.gather(req_conn.close(), global_connection_close())
43
-
44
- def handle_exception(fn):
45
- @wraps(fn)
46
- async def wrapper(*args, **kwargs):
47
- try:
48
- return await fn(*args, **kwargs)
49
- except Exception as e:
50
- if isinstance(e, HTTPException): raise e
51
- if isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
52
- if isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
53
- if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
54
- if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
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))
57
- if isinstance(e, DatabaseLockedError): raise HTTPException(status_code=503, detail=str(e))
58
- logger.error(f"Uncaptured error in {fn.__name__}: {e}")
59
- raise
60
- return wrapper
61
-
62
- async def get_credential_from_params(request: Request):
63
- return request.query_params.get("token")
64
- async def get_current_user(
65
- token: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)),
66
- q_token: Optional[str] = Depends(get_credential_from_params)
67
- ):
68
- """
69
- First try to get the user from the bearer token,
70
- if not found, try to get the user from the query parameter
71
- """
72
- async with unique_cursor() as conn:
73
- uconn = UserConn(conn)
74
- if token:
75
- user = await uconn.get_user_by_credential(token.credentials)
76
- else:
77
- if not q_token:
78
- return DECOY_USER
79
- else:
80
- user = await uconn.get_user_by_credential(q_token)
81
-
82
- if not user:
83
- raise HTTPException(status_code=401, detail="Invalid token")
84
-
85
- if not user.id == 0:
86
- await delayed_log_activity(user.username)
87
-
88
- return user
89
-
90
- async def registered_user(user: UserRecord = Depends(get_current_user)):
91
- if user.id == 0:
92
- raise HTTPException(status_code=401, detail="Permission denied")
93
- return user
94
-
95
- app = FastAPI(docs_url=None, redoc_url=None, lifespan=lifespan)
96
- app.add_middleware(
97
- CORSMiddleware,
98
- allow_origins=["*"],
99
- allow_credentials=True,
100
- allow_methods=["*"],
101
- allow_headers=["*"],
102
- )
103
-
104
- @app.middleware("http")
105
- async def log_requests(request: Request, call_next):
106
-
107
- request_time_stamp = now_stamp()
108
- start_time = time.perf_counter()
109
- response: Response = await call_next(request)
110
- end_time = time.perf_counter()
111
- response_time = end_time - start_time
112
- response.headers["X-Response-Time"] = str(response_time)
113
-
114
- if response.headers.get("X-Skip-Log", None) is not None:
115
- return response
116
-
117
- if response.status_code >= 400:
118
- logger_failed_request.error(f"{request.method} {request.url.path} {response.status_code}")
119
- await req_conn.log_request(
120
- request_time_stamp,
121
- request.method, request.url.path, response.status_code, response_time,
122
- headers = dict(request.headers),
123
- query = dict(request.query_params),
124
- client = request.client,
125
- request_size = int(request.headers.get("Content-Length", 0)),
126
- response_size = int(response.headers.get("Content-Length", 0))
127
- )
128
- await req_conn.ensure_commit_once()
129
- return response
130
-
131
- def skip_request_log(fn):
132
- @wraps(fn)
133
- async def wrapper(*args, **kwargs):
134
- response = await fn(*args, **kwargs)
135
- assert isinstance(response, Response), "Response expected"
136
- response.headers["X-Skip-Log"] = "1"
137
- return response
138
- return wrapper
139
-
140
- router_fs = APIRouter(prefix="")
141
-
142
- @skip_request_log
143
- async def emit_thumbnail(
144
- path: str, download: bool,
145
- create_time: Optional[str] = None,
146
- is_head = False
147
- ):
148
- if path.endswith("/"):
149
- fname = path.split("/")[-2]
150
- else:
151
- fname = path.split("/")[-1]
152
- if (thumb_res := await get_thumb(path)) is None:
153
- return Response(status_code=415, content="Thumbnail not supported")
154
- thumb_blob, mime_type = thumb_res
155
- disp = "inline" if not download else "attachment"
156
- headers = {
157
- "Content-Disposition": f"{disp}; filename={fname}.thumb.jpg",
158
- "Content-Length": str(len(thumb_blob)),
159
- }
160
- if create_time is not None:
161
- headers["Last-Modified"] = format_last_modified(create_time)
162
- if is_head: return Response(status_code=200, headers=headers)
163
- return Response(
164
- content=thumb_blob, media_type=mime_type, headers=headers
165
- )
166
- async def emit_file(
167
- file_record: FileRecord,
168
- media_type: Optional[str] = None,
169
- disposition = "attachment",
170
- is_head = False,
171
- range_start = -1,
172
- range_end = -1
173
- ):
174
- if range_start < 0: assert range_start == -1
175
- if range_end < 0: assert range_end == -1
176
-
177
- if media_type is None:
178
- media_type = file_record.mime_type
179
- path = file_record.url
180
- fname = path.split("/")[-1]
181
-
182
- if range_start == -1:
183
- arng_s = 0 # actual range start
184
- else:
185
- arng_s = range_start
186
- if range_end == -1:
187
- arng_e = file_record.file_size - 1
188
- else:
189
- arng_e = range_end
190
-
191
- if arng_s >= file_record.file_size or arng_e >= file_record.file_size:
192
- raise HTTPException(status_code=416, detail="Range not satisfiable")
193
- if arng_s > arng_e:
194
- raise HTTPException(status_code=416, detail="Invalid range")
195
-
196
- headers = {
197
- "Content-Disposition": f"{disposition}; filename={fname}",
198
- "Content-Length": str(arng_e - arng_s + 1),
199
- "Content-Range": f"bytes {arng_s}-{arng_e}/{file_record.file_size}",
200
- "Last-Modified": format_last_modified(file_record.create_time),
201
- "Accept-Ranges": "bytes",
202
- }
203
-
204
- if is_head: return Response(status_code=200 if (range_start == -1 and range_end == -1) else 206, headers=headers)
205
-
206
- await delayed_log_access(path)
207
- return StreamingResponse(
208
- await db.read_file(
209
- path,
210
- start_byte=arng_s if range_start != -1 else -1,
211
- end_byte=arng_e + 1 if range_end != -1 else -1
212
- ),
213
- media_type=media_type,
214
- headers=headers,
215
- status_code=206 if range_start != -1 or range_end != -1 else 200
216
- )
217
-
218
- async def get_file_impl(
219
- request: Request,
220
- user: UserRecord,
221
- path: str,
222
- download: bool = False,
223
- thumb: bool = False,
224
- is_head = False,
225
- ):
226
- path = ensure_uri_compnents(path)
227
-
228
- # handle directory query
229
- if path == "": path = "/"
230
- if path.endswith("/"):
231
- # return file under the path as json
232
- async with unique_cursor() as cur:
233
- fconn = FileConn(cur)
234
- if user.id == 0:
235
- raise HTTPException(status_code=401, detail="Permission denied, credential required")
236
- if thumb:
237
- return await emit_thumbnail(path, download, create_time=None)
238
-
239
- if path == "/":
240
- peer_users = await UserConn(cur).list_peer_users(user.id, AccessLevel.READ)
241
- return PathContents(
242
- dirs = await fconn.list_root_dirs(user.username, *[x.username for x in peer_users], skim=True) \
243
- if not user.is_admin else await fconn.list_root_dirs(skim=True),
244
- files = []
245
- )
246
-
247
- if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
248
- raise HTTPException(status_code=403, detail="Permission denied")
249
-
250
- return await fconn.list_path(path)
251
-
252
- # handle file query
253
- async with unique_cursor() as cur:
254
- fconn = FileConn(cur)
255
- file_record = await fconn.get_file_record(path, throw=True)
256
- uconn = UserConn(cur)
257
- owner = await uconn.get_user_by_id(file_record.owner_id, throw=True)
258
-
259
- if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
260
- allow_access, reason = check_file_read_permission(user, owner, file_record)
261
- if not allow_access:
262
- raise HTTPException(status_code=403 if user.id != 0 else 401, detail=reason)
263
-
264
- req_range = request.headers.get("Range", None)
265
- if not req_range is None:
266
- # handle range request
267
- if not req_range.startswith("bytes="):
268
- raise HTTPException(status_code=400, detail="Invalid range request")
269
- range_str = req_range[6:].strip()
270
- if "," in range_str:
271
- raise HTTPException(status_code=400, detail="Multiple ranges not supported")
272
- if "-" not in range_str:
273
- raise HTTPException(status_code=400, detail="Invalid range request")
274
- range_start, range_end = map(lambda x: int(x) if x != "" else -1 , range_str.split("-"))
275
- else:
276
- range_start, range_end = -1, -1
277
-
278
- if thumb:
279
- if (range_start != -1 or range_end != -1): logger.warning("Range request for thumbnail")
280
- return await emit_thumbnail(path, download, create_time=file_record.create_time, is_head=is_head)
281
- else:
282
- if download:
283
- return await emit_file(file_record, 'application/octet-stream', "attachment", is_head = is_head, range_start=range_start, range_end=range_end)
284
- else:
285
- return await emit_file(file_record, None, "inline", is_head = is_head, range_start=range_start, range_end=range_end)
286
-
287
- @router_fs.get("/{path:path}")
288
- @handle_exception
289
- async def get_file(
290
- request: Request,
291
- path: str,
292
- download: bool = False, thumb: bool = False,
293
- user: UserRecord = Depends(get_current_user)
294
- ):
295
- return await get_file_impl(
296
- request = request,
297
- user = user, path = path, download = download, thumb = thumb
298
- )
299
-
300
- @router_fs.head("/{path:path}")
301
- @handle_exception
302
- async def head_file(
303
- request: Request,
304
- path: str,
305
- download: bool = False, thumb: bool = False,
306
- user: UserRecord = Depends(get_current_user)
307
- ):
308
- if path.startswith("_api/"):
309
- raise HTTPException(status_code=405, detail="HEAD not supported for API")
310
- if path.endswith("/"):
311
- raise HTTPException(status_code=405, detail="HEAD not supported for directory")
312
- return await get_file_impl(
313
- request = request,
314
- user = user, path = path, download = download, thumb = thumb, is_head = True
315
- )
316
-
317
- @router_fs.put("/{path:path}")
318
- @handle_exception
319
- async def put_file(
320
- request: Request,
321
- path: str,
322
- conflict: Literal["overwrite", "skip", "abort"] = "abort",
323
- permission: int = 0,
324
- user: UserRecord = Depends(registered_user)
325
- ):
326
- path = ensure_uri_compnents(path)
327
- assert not path.endswith("/"), "Path must not end with /"
328
-
329
- access_level = await check_path_permission(path, user)
330
- if access_level < AccessLevel.WRITE:
331
- logger.debug(f"Reject put request from {user.username} to {path}")
332
- raise HTTPException(status_code=403, detail="Permission denied")
333
-
334
- logger.info(f"PUT {path}, user: {user.username}")
335
- exists_flag = False
336
- async with unique_cursor() as conn:
337
- fconn = FileConn(conn)
338
- file_record = await fconn.get_file_record(path)
339
-
340
- if file_record:
341
- if conflict == "abort":
342
- raise HTTPException(status_code=409, detail="File exists")
343
- if conflict == "skip":
344
- return Response(status_code=200, headers={
345
- "Content-Type": "application/json",
346
- }, content=json.dumps({"url": path}))
347
- exists_flag = True
348
- if await check_path_permission(path, user) < AccessLevel.WRITE:
349
- raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
350
- await db.delete_file(path)
351
-
352
- # check content-type
353
- content_type = request.headers.get("Content-Type")
354
- logger.debug(f"Content-Type: {content_type}")
355
- if not (content_type == "application/octet-stream" or content_type == "application/json"):
356
- raise HTTPException(status_code=415, detail="Unsupported content type, put request must be application/json or application/octet-stream")
357
-
358
- async def blob_reader():
359
- nonlocal request
360
- async for chunk in request.stream():
361
- yield chunk
362
-
363
- await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
364
-
365
- # https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
366
- return Response(status_code=200 if exists_flag else 201, headers={
367
- "Content-Type": "application/json",
368
- }, content=json.dumps({"url": path}))
369
-
370
- # using form-data instead of raw body
371
- @router_fs.post("/{path:path}")
372
- @handle_exception
373
- async def post_file(
374
- path: str,
375
- file: UploadFile,
376
- conflict: Literal["overwrite", "skip", "abort"] = "abort",
377
- permission: int = 0,
378
- user: UserRecord = Depends(registered_user)
379
- ):
380
- path = ensure_uri_compnents(path)
381
- assert not path.endswith("/"), "Path must not end with /"
382
-
383
- access_level = await check_path_permission(path, user)
384
- if access_level < AccessLevel.WRITE:
385
- logger.debug(f"Reject post request from {user.username} to {path}")
386
- raise HTTPException(status_code=403, detail="Permission denied")
387
-
388
- logger.info(f"POST {path}, user: {user.username}")
389
- exists_flag = False
390
- async with unique_cursor() as conn:
391
- fconn = FileConn(conn)
392
- file_record = await fconn.get_file_record(path)
393
-
394
- if file_record:
395
- if conflict == "abort":
396
- raise HTTPException(status_code=409, detail="File exists")
397
- if conflict == "skip":
398
- return Response(status_code=200, headers={
399
- "Content-Type": "application/json",
400
- }, content=json.dumps({"url": path}))
401
- exists_flag = True
402
- if await check_path_permission(path, user) < AccessLevel.WRITE:
403
- raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
404
- await db.delete_file(path)
405
-
406
- async def blob_reader():
407
- nonlocal file
408
- while (chunk := await file.read(CHUNK_SIZE)):
409
- yield chunk
410
-
411
- await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
412
- return Response(status_code=200 if exists_flag else 201, headers={
413
- "Content-Type": "application/json",
414
- }, content=json.dumps({"url": path}))
415
-
416
-
417
- @router_fs.delete("/{path:path}")
418
- @handle_exception
419
- async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
420
- path = ensure_uri_compnents(path)
421
- if await check_path_permission(path, user) < AccessLevel.WRITE:
422
- raise HTTPException(status_code=403, detail="Permission denied")
423
-
424
- logger.info(f"DELETE {path}, user: {user.username}")
425
-
426
- if path.endswith("/"):
427
- res = await db.delete_path(path, user)
428
- else:
429
- res = await db.delete_file(path, user)
430
-
431
- if res:
432
- return Response(status_code=200, content="Deleted")
433
- else:
434
- return Response(status_code=404, content="Not found")
435
-
436
- router_api = APIRouter(prefix="/_api")
437
-
438
- @router_api.get("/bundle")
439
- @handle_exception
440
- async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
441
- logger.info(f"GET bundle({path}), user: {user.username}")
442
- path = ensure_uri_compnents(path)
443
- assert path.endswith("/") or path == ""
444
-
445
- if not path == "" and path[0] == "/": # adapt to both /path and path
446
- path = path[1:]
447
-
448
- # TODO: may check peer users here
449
- owner_records_cache: dict[int, UserRecord] = {} # cache owner records, ID -> UserRecord
450
- async def is_access_granted(file_record: FileRecord):
451
- owner_id = file_record.owner_id
452
- owner = owner_records_cache.get(owner_id, None)
453
- if owner is None:
454
- async with unique_cursor() as conn:
455
- uconn = UserConn(conn)
456
- owner = await uconn.get_user_by_id(owner_id, throw=True)
457
- owner_records_cache[owner_id] = owner
458
-
459
- allow_access, _ = check_file_read_permission(user, owner, file_record)
460
- return allow_access
461
-
462
- async with unique_cursor() as conn:
463
- fconn = FileConn(conn)
464
- files = await fconn.list_path_files(
465
- url = path, flat = True,
466
- limit=(await fconn.count_path_files(url = path, flat = True))
467
- )
468
- files = [f for f in files if await is_access_granted(f)]
469
- if len(files) == 0:
470
- raise HTTPException(status_code=404, detail="No files found")
471
-
472
- # return bundle of files
473
- total_size = sum([f.file_size for f in files])
474
- if total_size > MAX_BUNDLE_BYTES:
475
- raise HTTPException(status_code=400, detail="Too large to zip")
476
-
477
- file_paths = [f.url for f in files]
478
- zip_buffer = await db.zip_path(path, file_paths)
479
- return Response(
480
- content=zip_buffer.getvalue(), media_type="application/zip", headers={
481
- "Content-Disposition": f"attachment; filename=bundle.zip",
482
- "Content-Length": str(zip_buffer.getbuffer().nbytes)
483
- }
484
- )
485
-
486
- @router_api.get("/meta")
487
- @handle_exception
488
- async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
489
- logger.info(f"GET meta({path}), user: {user.username}")
490
- path = ensure_uri_compnents(path)
491
- is_file = not path.endswith("/")
492
- async with unique_cursor() as cur:
493
- fconn = FileConn(cur)
494
- if is_file:
495
- record = await fconn.get_file_record(path, throw=True)
496
- if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
497
- uconn = UserConn(cur)
498
- owner = await uconn.get_user_by_id(record.owner_id, throw=True)
499
- is_allowed, reason = check_file_read_permission(user, owner, record)
500
- if not is_allowed:
501
- raise HTTPException(status_code=403, detail=reason)
502
- else:
503
- if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
504
- raise HTTPException(status_code=403, detail="Permission denied")
505
- record = await fconn.get_path_record(path)
506
- return record
507
-
508
- @router_api.post("/meta")
509
- @handle_exception
510
- async def update_file_meta(
511
- path: str,
512
- perm: Optional[int] = None,
513
- new_path: Optional[str] = None,
514
- user: UserRecord = Depends(registered_user)
515
- ):
516
- path = ensure_uri_compnents(path)
517
- if path.startswith("/"):
518
- path = path[1:]
519
-
520
- # file
521
- if not path.endswith("/"):
522
- if perm is not None:
523
- logger.info(f"Update permission of {path} to {perm}")
524
- await db.update_file_record(
525
- url = path,
526
- permission = FileReadPermission(perm),
527
- op_user = user,
528
- )
529
-
530
- if new_path is not None:
531
- new_path = ensure_uri_compnents(new_path)
532
- logger.info(f"Update path of {path} to {new_path}")
533
- await db.move_file(path, new_path, user)
534
-
535
- # directory
536
- else:
537
- assert perm is None, "Permission is not supported for directory"
538
- if new_path is not None:
539
- new_path = ensure_uri_compnents(new_path)
540
- logger.info(f"Update path of {path} to {new_path}")
541
- # currently only move own file, with overwrite
542
- await db.move_path(path, new_path, user)
543
-
544
- return Response(status_code=200, content="OK")
545
-
546
- async def validate_path_read_permission(path: str, user: UserRecord):
547
- if not path.endswith("/"):
548
- raise HTTPException(status_code=400, detail="Path must end with /")
549
- if not await check_path_permission(path, user) >= AccessLevel.READ:
550
- raise HTTPException(status_code=403, detail="Permission denied")
551
- @router_api.get("/count-files")
552
- async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
553
- await validate_path_read_permission(path, user)
554
- path = ensure_uri_compnents(path)
555
- async with unique_cursor() as conn:
556
- fconn = FileConn(conn)
557
- return { "count": await fconn.count_path_files(url = path, flat = flat) }
558
- @router_api.get("/list-files")
559
- async def list_files(
560
- path: str, offset: int = 0, limit: int = 1000,
561
- order_by: FileSortKey = "", order_desc: bool = False,
562
- flat: bool = False, user: UserRecord = Depends(registered_user)
563
- ):
564
- await validate_path_read_permission(path, user)
565
- path = ensure_uri_compnents(path)
566
- async with unique_cursor() as conn:
567
- fconn = FileConn(conn)
568
- return await fconn.list_path_files(
569
- url = path, offset = offset, limit = limit,
570
- order_by=order_by, order_desc=order_desc,
571
- flat=flat
572
- )
573
-
574
- @router_api.get("/count-dirs")
575
- async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
576
- await validate_path_read_permission(path, user)
577
- path = ensure_uri_compnents(path)
578
- async with unique_cursor() as conn:
579
- fconn = FileConn(conn)
580
- return { "count": await fconn.count_path_dirs(url = path) }
581
- @router_api.get("/list-dirs")
582
- async def list_dirs(
583
- path: str, offset: int = 0, limit: int = 1000,
584
- order_by: DirSortKey = "", order_desc: bool = False,
585
- skim: bool = True, user: UserRecord = Depends(registered_user)
586
- ):
587
- await validate_path_read_permission(path, user)
588
- path = ensure_uri_compnents(path)
589
- async with unique_cursor() as conn:
590
- fconn = FileConn(conn)
591
- return await fconn.list_path_dirs(
592
- url = path, offset = offset, limit = limit,
593
- order_by=order_by, order_desc=order_desc, skim=skim
594
- )
595
-
596
- @router_api.get("/whoami")
597
- @handle_exception
598
- async def whoami(user: UserRecord = Depends(registered_user)):
599
- user.credential = "__HIDDEN__"
600
- return user
601
-
602
- # order matters
603
- app.include_router(router_api)
604
- app.include_router(router_fs)
@@ -1,44 +0,0 @@
1
- Readme.md,sha256=J1tGk7B9EyIXT-RN7VGz_229UeKvZHVLpn1FvzNDxL4,1538
2
- docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
- docs/Permission.md,sha256=mvK8gVBBgoIFJqikcaReU_bUo-mTq_ECqJaDDJoQF7Q,3126
4
- frontend/api.js,sha256=hMV6Fc1JxkFQgv7BV1Y_Su7pqsWeF_92hPMmDBcXC04,18485
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=xGUJPCSrtDhuSu0ELLQZ77PmVWldg-prU1mwQGbdEoA,5797
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=nWH6NgavZTVmjK44i2DeRi6mJzGSe4qeQPUbDaEVt58,21735
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=46ViD2TlTTWy0fx6wjoAs_5CQ4ajYB90vVzM7UO2IHw,6182
17
- frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
18
- lfss/api/__init__.py,sha256=c_-GokKJ_3REgve16AwgXRfFyl9mwy7__FxCIIVlk1Q,6660
19
- lfss/api/connector.py,sha256=sSYy8gDewOosQiOzn8rvl7NsfFkIuhumHDefAVCgess,11573
20
- lfss/cli/__init__.py,sha256=lPwPmqpa7EXQ4zlU7E7LOe6X2kw_xATGdwoHphUEirA,827
21
- lfss/cli/balance.py,sha256=R2rbO2tg9TVnnQIVeU0GJVeMS-5LDhEdk4mbOE9qGq0,4121
22
- lfss/cli/cli.py,sha256=iQkZm5Ltlhw7EWM4gOv_N0vjxiteGDH_aGhh06YMPYk,8066
23
- lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
24
- lfss/cli/serve.py,sha256=T-jz_PJcaY9FJRRfyWV9MbjDI73YdOOCn4Nr2PO-s0c,993
25
- lfss/cli/user.py,sha256=4ynarVvSybxEQaoAzCn2dN2h6-9A61XDNQ-83-lwK4s,5364
26
- lfss/cli/vacuum.py,sha256=4TMMYC_5yEt7jeaFTVC3iX0X6aUzYXBiCsrcIYgw_uA,3695
27
- lfss/sql/init.sql,sha256=8LjHx0TBCkBD62xFfssSeHDqKYVQQJkZAg4rSm046f4,1496
28
- lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
29
- lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
- lfss/src/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
31
- lfss/src/config.py,sha256=7k_6L_aG7vD-lVcmgpwMIg-ggpMMDNU8IbaB4y1effE,861
32
- lfss/src/connection_pool.py,sha256=-tePasJxiZZ73ymgWf_kFnaKouc4Rrr4K6EXwjb7Mm4,6141
33
- lfss/src/database.py,sha256=jahoLa3kU6wGovwyfuvaMuGI8rLxcJYSfMkTT8_LI3c,41883
34
- lfss/src/datatype.py,sha256=27UB7-l9SICy5lAvKjdzpTL_GohZjzstQcr9PtAq7nM,2709
35
- lfss/src/error.py,sha256=oUQPkXzrlJ6Mtvm26T_3SYW_4j9unfudG3HMa1Q-JF0,421
36
- lfss/src/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
37
- lfss/src/server.py,sha256=TMsZBt-hF4dh_-e_v5odki09S36kJ33Gi_GbtUnGQ-M,23310
38
- lfss/src/stat.py,sha256=WlRiiAl0rHX9oPi3thi6K4GKn70WHpClSDCt7iZUow4,3197
39
- lfss/src/thumb.py,sha256=qjCNMpnCozMuzkhm-2uAYy1eAuYTeWG6xqs-13HX-7k,3266
40
- lfss/src/utils.py,sha256=XHBDBODU6RPpYywArmMBVnghPk3crPLrSW4cKxqiP5Q,5887
41
- lfss-0.9.0.dist-info/METADATA,sha256=QNSYV-4VzdS4VCmKtSvjSMurXTb1qvUhvW-Wvpz3dcA,2298
42
- lfss-0.9.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
43
- lfss-0.9.0.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
44
- lfss-0.9.0.dist-info/RECORD,,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes