lfss 0.8.4__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/svc/app_native.py ADDED
@@ -0,0 +1,247 @@
1
+ from typing import Optional, Literal
2
+
3
+ from fastapi import Depends, Request, Response, UploadFile
4
+ from fastapi.exceptions import HTTPException
5
+
6
+ from ..eng.config import MAX_BUNDLE_BYTES
7
+ from ..eng.utils import ensure_uri_compnents
8
+ from ..eng.connection_pool import unique_cursor
9
+ from ..eng.database import check_file_read_permission, check_path_permission, UserConn, FileConn
10
+ from ..eng.datatype import (
11
+ FileReadPermission, FileRecord, UserRecord, AccessLevel,
12
+ FileSortKey, DirSortKey
13
+ )
14
+
15
+ from .app_base import *
16
+ from .common_impl import get_file_impl, put_file_impl, post_file_impl, delete_file_impl
17
+
18
+ @router_fs.get("/{path:path}")
19
+ @handle_exception
20
+ async def get_file(
21
+ request: Request,
22
+ path: str,
23
+ download: bool = False, thumb: bool = False,
24
+ user: UserRecord = Depends(get_current_user)
25
+ ):
26
+ return await get_file_impl(
27
+ request = request,
28
+ user = user, path = path, download = download, thumb = thumb
29
+ )
30
+
31
+ @router_fs.head("/{path:path}")
32
+ @handle_exception
33
+ async def head_file(
34
+ request: Request,
35
+ path: str,
36
+ download: bool = False, thumb: bool = False,
37
+ user: UserRecord = Depends(get_current_user)
38
+ ):
39
+ if path.startswith("_api/"):
40
+ raise HTTPException(status_code=405, detail="HEAD not supported for API")
41
+ if path.endswith("/"):
42
+ raise HTTPException(status_code=405, detail="HEAD not supported for directory")
43
+ return await get_file_impl(
44
+ request = request,
45
+ user = user, path = path, download = download, thumb = thumb, is_head = True
46
+ )
47
+
48
+ @router_fs.put("/{path:path}")
49
+ @handle_exception
50
+ async def put_file(
51
+ request: Request,
52
+ path: str,
53
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
54
+ permission: int = 0,
55
+ user: UserRecord = Depends(registered_user)
56
+ ):
57
+ return await put_file_impl(
58
+ request = request, user = user, path = path, conflict = conflict, permission = permission
59
+ )
60
+
61
+ # using form-data instead of raw body
62
+ @router_fs.post("/{path:path}")
63
+ @handle_exception
64
+ async def post_file(
65
+ path: str,
66
+ file: UploadFile,
67
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
68
+ permission: int = 0,
69
+ user: UserRecord = Depends(registered_user)
70
+ ):
71
+ return await post_file_impl(
72
+ file = file, user = user, path = path, conflict = conflict, permission = permission
73
+ )
74
+
75
+ @router_fs.delete("/{path:path}")
76
+ @handle_exception
77
+ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
78
+ return await delete_file_impl(path, user)
79
+
80
+
81
+ @router_api.get("/bundle")
82
+ @handle_exception
83
+ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
84
+ logger.info(f"GET bundle({path}), user: {user.username}")
85
+ path = ensure_uri_compnents(path)
86
+ assert path.endswith("/") or path == ""
87
+
88
+ if not path == "" and path[0] == "/": # adapt to both /path and path
89
+ path = path[1:]
90
+
91
+ # TODO: may check peer users here
92
+ owner_records_cache: dict[int, UserRecord] = {} # cache owner records, ID -> UserRecord
93
+ async def is_access_granted(file_record: FileRecord):
94
+ owner_id = file_record.owner_id
95
+ owner = owner_records_cache.get(owner_id, None)
96
+ if owner is None:
97
+ async with unique_cursor() as conn:
98
+ uconn = UserConn(conn)
99
+ owner = await uconn.get_user_by_id(owner_id, throw=True)
100
+ owner_records_cache[owner_id] = owner
101
+
102
+ allow_access, _ = check_file_read_permission(user, owner, file_record)
103
+ return allow_access
104
+
105
+ async with unique_cursor() as conn:
106
+ fconn = FileConn(conn)
107
+ files = await fconn.list_path_files(
108
+ url = path, flat = True,
109
+ limit=(await fconn.count_path_files(url = path, flat = True))
110
+ )
111
+ files = [f for f in files if await is_access_granted(f)]
112
+ if len(files) == 0:
113
+ raise HTTPException(status_code=404, detail="No files found")
114
+
115
+ # return bundle of files
116
+ total_size = sum([f.file_size for f in files])
117
+ if total_size > MAX_BUNDLE_BYTES:
118
+ raise HTTPException(status_code=400, detail="Too large to zip")
119
+
120
+ file_paths = [f.url for f in files]
121
+ zip_buffer = await db.zip_path(path, file_paths)
122
+ return Response(
123
+ content=zip_buffer.getvalue(), media_type="application/zip", headers={
124
+ "Content-Disposition": f"attachment; filename=bundle.zip",
125
+ "Content-Length": str(zip_buffer.getbuffer().nbytes)
126
+ }
127
+ )
128
+
129
+ @router_api.get("/meta")
130
+ @handle_exception
131
+ async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
132
+ logger.info(f"GET meta({path}), user: {user.username}")
133
+ path = ensure_uri_compnents(path)
134
+ is_file = not path.endswith("/")
135
+ async with unique_cursor() as cur:
136
+ fconn = FileConn(cur)
137
+ if is_file:
138
+ record = await fconn.get_file_record(path, throw=True)
139
+ if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
140
+ uconn = UserConn(cur)
141
+ owner = await uconn.get_user_by_id(record.owner_id, throw=True)
142
+ is_allowed, reason = check_file_read_permission(user, owner, record)
143
+ if not is_allowed:
144
+ raise HTTPException(status_code=403, detail=reason)
145
+ else:
146
+ if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
147
+ raise HTTPException(status_code=403, detail="Permission denied")
148
+ record = await fconn.get_path_record(path)
149
+ return record
150
+
151
+ @router_api.post("/meta")
152
+ @handle_exception
153
+ async def update_file_meta(
154
+ path: str,
155
+ perm: Optional[int] = None,
156
+ new_path: Optional[str] = None,
157
+ user: UserRecord = Depends(registered_user)
158
+ ):
159
+ path = ensure_uri_compnents(path)
160
+ if path.startswith("/"):
161
+ path = path[1:]
162
+
163
+ # file
164
+ if not path.endswith("/"):
165
+ if perm is not None:
166
+ logger.info(f"Update permission of {path} to {perm}")
167
+ await db.update_file_record(
168
+ url = path,
169
+ permission = FileReadPermission(perm),
170
+ op_user = user,
171
+ )
172
+
173
+ if new_path is not None:
174
+ new_path = ensure_uri_compnents(new_path)
175
+ logger.info(f"Update path of {path} to {new_path}")
176
+ await db.move_file(path, new_path, user)
177
+
178
+ # directory
179
+ else:
180
+ assert perm is None, "Permission is not supported for directory"
181
+ if new_path is not None:
182
+ new_path = ensure_uri_compnents(new_path)
183
+ logger.info(f"Update path of {path} to {new_path}")
184
+ # currently only move own file, with overwrite
185
+ await db.move_path(path, new_path, user)
186
+
187
+ return Response(status_code=200, content="OK")
188
+
189
+ async def validate_path_read_permission(path: str, user: UserRecord):
190
+ if not path.endswith("/"):
191
+ raise HTTPException(status_code=400, detail="Path must end with /")
192
+ if not await check_path_permission(path, user) >= AccessLevel.READ:
193
+ raise HTTPException(status_code=403, detail="Permission denied")
194
+ @router_api.get("/count-files")
195
+ async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
196
+ await validate_path_read_permission(path, user)
197
+ path = ensure_uri_compnents(path)
198
+ async with unique_cursor() as conn:
199
+ fconn = FileConn(conn)
200
+ return { "count": await fconn.count_path_files(url = path, flat = flat) }
201
+ @router_api.get("/list-files")
202
+ async def list_files(
203
+ path: str, offset: int = 0, limit: int = 1000,
204
+ order_by: FileSortKey = "", order_desc: bool = False,
205
+ flat: bool = False, user: UserRecord = Depends(registered_user)
206
+ ):
207
+ await validate_path_read_permission(path, user)
208
+ path = ensure_uri_compnents(path)
209
+ async with unique_cursor() as conn:
210
+ fconn = FileConn(conn)
211
+ return await fconn.list_path_files(
212
+ url = path, offset = offset, limit = limit,
213
+ order_by=order_by, order_desc=order_desc,
214
+ flat=flat
215
+ )
216
+
217
+ @router_api.get("/count-dirs")
218
+ async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
219
+ await validate_path_read_permission(path, user)
220
+ path = ensure_uri_compnents(path)
221
+ async with unique_cursor() as conn:
222
+ fconn = FileConn(conn)
223
+ return { "count": await fconn.count_path_dirs(url = path) }
224
+ @router_api.get("/list-dirs")
225
+ async def list_dirs(
226
+ path: str, offset: int = 0, limit: int = 1000,
227
+ order_by: DirSortKey = "", order_desc: bool = False,
228
+ skim: bool = True, user: UserRecord = Depends(registered_user)
229
+ ):
230
+ await validate_path_read_permission(path, user)
231
+ path = ensure_uri_compnents(path)
232
+ async with unique_cursor() as conn:
233
+ fconn = FileConn(conn)
234
+ return await fconn.list_path_dirs(
235
+ url = path, offset = offset, limit = limit,
236
+ order_by=order_by, order_desc=order_desc, skim=skim
237
+ )
238
+
239
+ @router_api.get("/whoami")
240
+ @handle_exception
241
+ async def whoami(user: UserRecord = Depends(registered_user)):
242
+ user.credential = "__HIDDEN__"
243
+ return user
244
+
245
+ __all__ = [
246
+ "app", "router_api", "router_fs"
247
+ ]
@@ -0,0 +1,270 @@
1
+ import json
2
+ from fastapi import Request, Response, HTTPException, UploadFile
3
+ from fastapi.responses import StreamingResponse
4
+ from typing import Optional, Literal
5
+ from ..eng.connection_pool import unique_cursor
6
+ from ..eng.datatype import UserRecord, FileRecord, PathContents, AccessLevel, FileReadPermission
7
+ from ..eng.database import FileConn, UserConn, delayed_log_access, check_file_read_permission, check_path_permission
8
+ from ..eng.thumb import get_thumb
9
+ from ..eng.utils import format_last_modified, ensure_uri_compnents
10
+ from ..eng.config import CHUNK_SIZE
11
+
12
+ from .app_base import skip_request_log, db, logger
13
+
14
+ @skip_request_log
15
+ async def emit_thumbnail(
16
+ path: str, download: bool,
17
+ create_time: Optional[str] = None,
18
+ is_head = False
19
+ ):
20
+ if path.endswith("/"):
21
+ fname = path.split("/")[-2]
22
+ else:
23
+ fname = path.split("/")[-1]
24
+ if (thumb_res := await get_thumb(path)) is None:
25
+ return Response(status_code=415, content="Thumbnail not supported")
26
+ thumb_blob, mime_type = thumb_res
27
+ disp = "inline" if not download else "attachment"
28
+ headers = {
29
+ "Content-Disposition": f"{disp}; filename={fname}.thumb.jpg",
30
+ "Content-Length": str(len(thumb_blob)),
31
+ }
32
+ if create_time is not None:
33
+ headers["Last-Modified"] = format_last_modified(create_time)
34
+ if is_head: return Response(status_code=200, headers=headers)
35
+ return Response(
36
+ content=thumb_blob, media_type=mime_type, headers=headers
37
+ )
38
+ async def emit_file(
39
+ file_record: FileRecord,
40
+ media_type: Optional[str] = None,
41
+ disposition = "attachment",
42
+ is_head = False,
43
+ range_start = -1,
44
+ range_end = -1
45
+ ):
46
+ if range_start < 0: assert range_start == -1
47
+ if range_end < 0: assert range_end == -1
48
+
49
+ if media_type is None:
50
+ media_type = file_record.mime_type
51
+ path = file_record.url
52
+ fname = path.split("/")[-1]
53
+
54
+ if range_start == -1:
55
+ arng_s = 0 # actual range start
56
+ else:
57
+ arng_s = range_start
58
+ if range_end == -1:
59
+ arng_e = file_record.file_size - 1
60
+ else:
61
+ arng_e = range_end
62
+
63
+ if arng_s >= file_record.file_size or arng_e >= file_record.file_size:
64
+ raise HTTPException(status_code=416, detail="Range not satisfiable")
65
+ if arng_s > arng_e:
66
+ raise HTTPException(status_code=416, detail="Invalid range")
67
+
68
+ headers = {
69
+ "Content-Disposition": f"{disposition}; filename={fname}",
70
+ "Content-Length": str(arng_e - arng_s + 1),
71
+ "Content-Range": f"bytes {arng_s}-{arng_e}/{file_record.file_size}",
72
+ "Last-Modified": format_last_modified(file_record.create_time),
73
+ "Accept-Ranges": "bytes",
74
+ }
75
+
76
+ if is_head: return Response(status_code=200 if (range_start == -1 and range_end == -1) else 206, headers=headers)
77
+
78
+ await delayed_log_access(path)
79
+ return StreamingResponse(
80
+ await db.read_file(
81
+ path,
82
+ start_byte=arng_s if range_start != -1 else -1,
83
+ end_byte=arng_e + 1 if range_end != -1 else -1
84
+ ),
85
+ media_type=media_type,
86
+ headers=headers,
87
+ status_code=206 if range_start != -1 or range_end != -1 else 200
88
+ )
89
+
90
+ async def get_file_impl(
91
+ request: Request,
92
+ user: UserRecord,
93
+ path: str,
94
+ download: bool = False,
95
+ thumb: bool = False,
96
+ is_head = False,
97
+ ):
98
+ path = ensure_uri_compnents(path)
99
+
100
+ # handle directory query
101
+ if path == "": path = "/"
102
+ if path.endswith("/"):
103
+ # return file under the path as json
104
+ async with unique_cursor() as cur:
105
+ fconn = FileConn(cur)
106
+ if user.id == 0:
107
+ raise HTTPException(status_code=401, detail="Permission denied, credential required")
108
+ if thumb:
109
+ return await emit_thumbnail(path, download, create_time=None)
110
+
111
+ if path == "/":
112
+ peer_users = await UserConn(cur).list_peer_users(user.id, AccessLevel.READ)
113
+ return PathContents(
114
+ dirs = await fconn.list_root_dirs(user.username, *[x.username for x in peer_users], skim=True) \
115
+ if not user.is_admin else await fconn.list_root_dirs(skim=True),
116
+ files = []
117
+ )
118
+
119
+ if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
120
+ raise HTTPException(status_code=403, detail="Permission denied")
121
+
122
+ return await fconn.list_path(path)
123
+
124
+ # handle file query
125
+ async with unique_cursor() as cur:
126
+ fconn = FileConn(cur)
127
+ file_record = await fconn.get_file_record(path, throw=True)
128
+ uconn = UserConn(cur)
129
+ owner = await uconn.get_user_by_id(file_record.owner_id, throw=True)
130
+
131
+ if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
132
+ allow_access, reason = check_file_read_permission(user, owner, file_record)
133
+ if not allow_access:
134
+ raise HTTPException(status_code=403 if user.id != 0 else 401, detail=reason)
135
+
136
+ req_range = request.headers.get("Range", None)
137
+ if not req_range is None:
138
+ # handle range request
139
+ if not req_range.startswith("bytes="):
140
+ raise HTTPException(status_code=400, detail="Invalid range request")
141
+ range_str = req_range[6:].strip()
142
+ if "," in range_str:
143
+ raise HTTPException(status_code=400, detail="Multiple ranges not supported")
144
+ if "-" not in range_str:
145
+ raise HTTPException(status_code=400, detail="Invalid range request")
146
+ range_start, range_end = map(lambda x: int(x) if x != "" else -1 , range_str.split("-"))
147
+ else:
148
+ range_start, range_end = -1, -1
149
+
150
+ if thumb:
151
+ if (range_start != -1 or range_end != -1): logger.warning("Range request for thumbnail")
152
+ return await emit_thumbnail(path, download, create_time=file_record.create_time, is_head=is_head)
153
+ else:
154
+ if download:
155
+ return await emit_file(file_record, 'application/octet-stream', "attachment", is_head = is_head, range_start=range_start, range_end=range_end)
156
+ else:
157
+ return await emit_file(file_record, None, "inline", is_head = is_head, range_start=range_start, range_end=range_end)
158
+
159
+ async def put_file_impl(
160
+ request: Request,
161
+ user: UserRecord,
162
+ path: str,
163
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
164
+ permission: int = 0,
165
+ ):
166
+ path = ensure_uri_compnents(path)
167
+ assert not path.endswith("/"), "Path must not end with /"
168
+
169
+ access_level = await check_path_permission(path, user)
170
+ if access_level < AccessLevel.WRITE:
171
+ logger.debug(f"Reject put request from {user.username} to {path}")
172
+ raise HTTPException(status_code=403, detail="Permission denied")
173
+
174
+ logger.info(f"PUT {path}, user: {user.username}")
175
+ exists_flag = False
176
+ async with unique_cursor() as conn:
177
+ fconn = FileConn(conn)
178
+ file_record = await fconn.get_file_record(path)
179
+
180
+ if file_record:
181
+ if conflict == "abort":
182
+ raise HTTPException(status_code=409, detail="File exists")
183
+ if conflict == "skip":
184
+ return Response(status_code=200, headers={
185
+ "Content-Type": "application/json",
186
+ }, content=json.dumps({"url": path}))
187
+ exists_flag = True
188
+ if await check_path_permission(path, user) < AccessLevel.WRITE:
189
+ raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
190
+ await db.delete_file(path)
191
+
192
+ # check content-type
193
+ content_type = request.headers.get("Content-Type", "application/octet-stream")
194
+ logger.debug(f"Content-Type: {content_type}")
195
+ if not (content_type == "application/octet-stream" or content_type == "application/json"):
196
+ # raise HTTPException(status_code=415, detail="Unsupported content type, put request must be application/json or application/octet-stream, got " + content_type)
197
+ logger.warning(f"Unsupported content type, put request must be application/json or application/octet-stream, got {content_type}")
198
+
199
+ async def blob_reader():
200
+ nonlocal request
201
+ async for chunk in request.stream():
202
+ yield chunk
203
+
204
+ await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
205
+
206
+ # https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
207
+ return Response(status_code=200 if exists_flag else 201, headers={
208
+ "Content-Type": "application/json",
209
+ }, content=json.dumps({"url": path}))
210
+
211
+
212
+ async def post_file_impl(
213
+ path: str,
214
+ user: UserRecord,
215
+ file: UploadFile,
216
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
217
+ permission: int = 0,
218
+ ):
219
+ path = ensure_uri_compnents(path)
220
+ assert not path.endswith("/"), "Path must not end with /"
221
+
222
+ access_level = await check_path_permission(path, user)
223
+ if access_level < AccessLevel.WRITE:
224
+ logger.debug(f"Reject post request from {user.username} to {path}")
225
+ raise HTTPException(status_code=403, detail="Permission denied")
226
+
227
+ logger.info(f"POST {path}, user: {user.username}")
228
+ exists_flag = False
229
+ async with unique_cursor() as conn:
230
+ fconn = FileConn(conn)
231
+ file_record = await fconn.get_file_record(path)
232
+
233
+ if file_record:
234
+ if conflict == "abort":
235
+ raise HTTPException(status_code=409, detail="File exists")
236
+ if conflict == "skip":
237
+ return Response(status_code=200, headers={
238
+ "Content-Type": "application/json",
239
+ }, content=json.dumps({"url": path}))
240
+ exists_flag = True
241
+ if await check_path_permission(path, user) < AccessLevel.WRITE:
242
+ raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
243
+ await db.delete_file(path)
244
+
245
+ async def blob_reader():
246
+ nonlocal file
247
+ while (chunk := await file.read(CHUNK_SIZE)):
248
+ yield chunk
249
+
250
+ await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
251
+ return Response(status_code=200 if exists_flag else 201, headers={
252
+ "Content-Type": "application/json",
253
+ }, content=json.dumps({"url": path}))
254
+
255
+ async def delete_file_impl(path: str, user: UserRecord):
256
+ path = ensure_uri_compnents(path)
257
+ if await check_path_permission(path, user) < AccessLevel.WRITE:
258
+ raise HTTPException(status_code=403, detail="Permission denied")
259
+
260
+ logger.info(f"DELETE {path}, user: {user.username}")
261
+
262
+ if path.endswith("/"):
263
+ res = await db.delete_path(path, user)
264
+ else:
265
+ res = await db.delete_file(path, user)
266
+
267
+ if res:
268
+ return Response(status_code=200, content="Deleted")
269
+ else:
270
+ return Response(status_code=404, content="Not found")
@@ -1,8 +1,8 @@
1
1
  from typing import Optional, Any
2
2
  import aiosqlite
3
3
  from contextlib import asynccontextmanager
4
- from .config import DATA_HOME
5
- from .utils import debounce_async
4
+ from ..eng.config import DATA_HOME
5
+ from ..eng.utils import debounce_async
6
6
 
7
7
  class RequestDB:
8
8
  conn: aiosqlite.Connection
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.8.4
3
+ Version: 0.9.1
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
- Author: li, mengxun
7
- Author-email: limengxun45@outlook.com
6
+ Author: li_mengxun
7
+ Author-email: limengxun45@outlookc.com
8
8
  Requires-Python: >=3.10
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3.10
@@ -32,6 +32,7 @@ My experiment on a lightweight and high-performance file/object storage service.
32
32
  - Pagination and sorted file listing for vast number of files.
33
33
  - High performance: high concurrency, near-native speed on stress tests.
34
34
  - Support range requests, so you can stream large files / resume download.
35
+ - WebDAV compatible ([NOTE](./docs/Webdav.md)).
35
36
 
36
37
  It stores small files and metadata in sqlite, large files in the filesystem.
37
38
  Tested on 2 million files, and it is still fast.
@@ -53,7 +54,11 @@ lfss-panel --open
53
54
  Or, you can start a web server at `/frontend` and open `index.html` in your browser.
54
55
 
55
56
  The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
56
- Authentication via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
57
+ The authentication can be acheived through one of the following methods:
58
+ 1. `Authorization` header with the value `Bearer sha256(<username><password>)`.
59
+ 2. `token` query parameter with the value `sha256(<username><password>)`.
60
+ 3. HTTP Basic Authentication with the username and password.
61
+
57
62
  You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
58
63
 
59
64
  By default, the service exposes all files to the public for `GET` requests,
@@ -0,0 +1,49 @@
1
+ Readme.md,sha256=6gOvhb93ma83VKC4-pfi4TccZxCq4kj3GEB9qa9ols4,1759
2
+ docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
+ docs/Permission.md,sha256=mvK8gVBBgoIFJqikcaReU_bUo-mTq_ECqJaDDJoQF7Q,3126
4
+ docs/Webdav.md,sha256=9Q41ROEJodVVAnlo1Tf0jqsyrbuHhv_ElSsXbIPXYIg,1547
5
+ frontend/api.js,sha256=hMV6Fc1JxkFQgv7BV1Y_Su7pqsWeF_92hPMmDBcXC04,18485
6
+ frontend/index.html,sha256=-k0bJ5FRqdl_H-O441D_H9E-iejgRCaL_z5UeYaS2qc,3384
7
+ frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
8
+ frontend/info.js,sha256=xGUJPCSrtDhuSu0ELLQZ77PmVWldg-prU1mwQGbdEoA,5797
9
+ frontend/login.css,sha256=VMM0QfbDFYerxKWKSGhMI1yg5IRBXg0TTdLJEEhQZNk,355
10
+ frontend/login.js,sha256=QoO8yKmBHDVP-ZomCMOaV7xVUVIhpl7esJrb6T5aHQE,2466
11
+ frontend/popup.css,sha256=TJZYFW1ZcdD1IVTlNPYNtMWKPbN6XDbQ4hKBOFK8uLg,1284
12
+ frontend/popup.js,sha256=3PgaGZmxSdV1E-D_MWgcR7aHWkcsHA1BNKSOkmP66tA,5191
13
+ frontend/scripts.js,sha256=nWH6NgavZTVmjK44i2DeRi6mJzGSe4qeQPUbDaEVt58,21735
14
+ frontend/state.js,sha256=vbNL5DProRKmSEY7xu9mZH6IY0PBenF8WGxPtGgDnLI,1680
15
+ frontend/styles.css,sha256=xcNLqI3KBsY5TLnku8UIP0Jfr7QLajr1_KNlZj9eheM,4935
16
+ frontend/thumb.css,sha256=rNsx766amYS2DajSQNabhpQ92gdTpNoQKmV69OKvtpI,295
17
+ frontend/thumb.js,sha256=46ViD2TlTTWy0fx6wjoAs_5CQ4ajYB90vVzM7UO2IHw,6182
18
+ frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
19
+ lfss/api/__init__.py,sha256=8IJqrpWK1doIyVVbntvVic82A57ncwl5b0BRHX4Ri6A,6660
20
+ lfss/api/connector.py,sha256=gLn-eW1m6trjqj54YXzPqByQFT56WlSy08kUm1UX4LE,11573
21
+ lfss/cli/__init__.py,sha256=lPwPmqpa7EXQ4zlU7E7LOe6X2kw_xATGdwoHphUEirA,827
22
+ lfss/cli/balance.py,sha256=fUbKKAUyaDn74f7mmxMfBL4Q4voyBLHu6Lg_g8GfMOQ,4121
23
+ lfss/cli/cli.py,sha256=aYjB8d4k6JUd9efxZK-XOj-mlG4JeOr_0lnj2qqCiK0,8066
24
+ lfss/cli/panel.py,sha256=Xq3I_n-ctveym-Gh9LaUpzHiLlvt3a_nuDiwUS-MGrg,1597
25
+ lfss/cli/serve.py,sha256=vTo6_BiD7Dn3VLvHsC5RKRBC3lMu45JVr_0SqpgHdj0,1086
26
+ lfss/cli/user.py,sha256=1mTroQbaKxHjFCPHT67xwd08v-zxH0RZ_OnVc-4MzL0,5364
27
+ lfss/cli/vacuum.py,sha256=GOG72d3NYe9bYCNc3y8JecEmM-DrKlGq3JQcisv_xBg,3702
28
+ lfss/eng/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
+ lfss/eng/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
30
+ lfss/eng/config.py,sha256=DmnUYMeLOL-45OstysyMpSBPmLofgzvcSrsWjHvssYs,915
31
+ lfss/eng/connection_pool.py,sha256=-tePasJxiZZ73ymgWf_kFnaKouc4Rrr4K6EXwjb7Mm4,6141
32
+ lfss/eng/database.py,sha256=81wp6LczdByk05RYcJfLjd0tx4ZT3Ue2k994UYMiDZI,47172
33
+ lfss/eng/datatype.py,sha256=27UB7-l9SICy5lAvKjdzpTL_GohZjzstQcr9PtAq7nM,2709
34
+ lfss/eng/error.py,sha256=61hcjeQ-y5htKhImDtDuWDf0GPrwbj5LySibeHiKFxc,529
35
+ lfss/eng/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
36
+ lfss/eng/thumb.py,sha256=YO1yTI8WzW7pBpQN9x5PtPayxhftb32IJl1zPSS9mks,3243
37
+ lfss/eng/utils.py,sha256=zZ7r9BsNV8XJJVNOxfIqRCO1bxNzh7bc9vEJiCkgbKI,6208
38
+ lfss/sql/init.sql,sha256=8LjHx0TBCkBD62xFfssSeHDqKYVQQJkZAg4rSm046f4,1496
39
+ lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
40
+ lfss/svc/app.py,sha256=XK0hx5yKMk8JViJ2BXgsFr3hgSdWn_equJ0JoJwMiuc,221
41
+ lfss/svc/app_base.py,sha256=LjH9tJzaFcP7PB8OBUizG9kvNhts2eq0JxNMBUCoHw0,6312
42
+ lfss/svc/app_dav.py,sha256=8Etdoh_NmvZpEG8J9m97xRF4i-GNGJNvNfgjfLIDjzk,17224
43
+ lfss/svc/app_native.py,sha256=ML-PR-zdoi5j3-KrGhIaYPMbSoW9Lesh7NROPNPUINU,9221
44
+ lfss/svc/common_impl.py,sha256=m3lMA97a4yd_VFG3IrVYIsDzyUEseX7sU633qj7iyDI,10858
45
+ lfss/svc/request_log.py,sha256=v8yXEIzPjaksu76Oh5vgdbUEUrw8Kt4etLAXBWSGie8,3207
46
+ lfss-0.9.1.dist-info/METADATA,sha256=CZuVoVTKJG002grUK_hXtgy0b_j3m-eNHDK0SRyVLL4,2519
47
+ lfss-0.9.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
48
+ lfss-0.9.1.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
49
+ lfss-0.9.1.dist-info/RECORD,,