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