lfss 0.3.1__py3-none-any.whl → 0.3.2__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/log.py +2 -0
- lfss/src/server.py +53 -20
- lfss/src/stat.py +65 -0
- lfss/src/utils.py +29 -1
- {lfss-0.3.1.dist-info → lfss-0.3.2.dist-info}/METADATA +1 -1
- {lfss-0.3.1.dist-info → lfss-0.3.2.dist-info}/RECORD +8 -7
- {lfss-0.3.1.dist-info → lfss-0.3.2.dist-info}/WHEEL +0 -0
- {lfss-0.3.1.dist-info → lfss-0.3.2.dist-info}/entry_points.txt +0 -0
lfss/src/log.py
CHANGED
@@ -64,6 +64,7 @@ def get_logger(
|
|
64
64
|
name = 'default',
|
65
65
|
log_home = pathlib.Path(DATA_HOME) / 'logs',
|
66
66
|
level = 'DEBUG',
|
67
|
+
term_level = 'INFO',
|
67
68
|
file_handler_type: _fh_T = 'rotate',
|
68
69
|
global_instance = True
|
69
70
|
)->BaseLogger:
|
@@ -77,6 +78,7 @@ def get_logger(
|
|
77
78
|
formatter = logging.Formatter(format_str)
|
78
79
|
console_handler = logging.StreamHandler()
|
79
80
|
console_handler.setFormatter(formatter)
|
81
|
+
console_handler.setLevel(term_level)
|
80
82
|
logger.addHandler(console_handler)
|
81
83
|
|
82
84
|
# format_str_plain = format_str.replace(BCOLORS.LIGHTMAGENTA, '').replace(BCOLORS.OKCYAN, '').replace(BCOLORS.ENDC, '')
|
lfss/src/server.py
CHANGED
@@ -7,26 +7,29 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
7
7
|
from fastapi.middleware.cors import CORSMiddleware
|
8
8
|
import mimesniff
|
9
9
|
|
10
|
-
import json
|
10
|
+
import asyncio, json, time
|
11
11
|
import mimetypes
|
12
12
|
from contextlib import asynccontextmanager
|
13
13
|
|
14
14
|
from .error import *
|
15
15
|
from .log import get_logger
|
16
|
+
from .stat import RequestDB
|
16
17
|
from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES
|
17
18
|
from .utils import ensure_uri_compnents
|
18
19
|
from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission
|
19
20
|
|
20
|
-
logger = get_logger("server")
|
21
|
+
logger = get_logger("server", term_level="DEBUG")
|
22
|
+
logger_failed_request = get_logger("failed_requests", term_level="INFO")
|
21
23
|
conn = Database()
|
24
|
+
req_conn = RequestDB()
|
22
25
|
|
23
26
|
@asynccontextmanager
|
24
27
|
async def lifespan(app: FastAPI):
|
25
28
|
global conn
|
26
|
-
await conn.init()
|
29
|
+
await asyncio.gather(conn.init(), req_conn.init())
|
27
30
|
yield
|
28
|
-
await conn.commit()
|
29
|
-
await conn.close()
|
31
|
+
await asyncio.gather(conn.commit(), req_conn.commit())
|
32
|
+
await asyncio.gather(conn.close(), req_conn.close())
|
30
33
|
|
31
34
|
def handle_exception(fn):
|
32
35
|
@wraps(fn)
|
@@ -34,14 +37,14 @@ def handle_exception(fn):
|
|
34
37
|
try:
|
35
38
|
return await fn(*args, **kwargs)
|
36
39
|
except Exception as e:
|
37
|
-
logger.error(f"Error in {fn.__name__}: {e}")
|
38
40
|
if isinstance(e, HTTPException): raise e
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
41
|
+
if isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
|
42
|
+
if isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
|
43
|
+
if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
|
44
|
+
if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
|
45
|
+
if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
|
46
|
+
logger.error(f"Uncaptured error in {fn.__name__}: {e}")
|
47
|
+
raise HTTPException(status_code=500, detail=str(e))
|
45
48
|
return wrapper
|
46
49
|
|
47
50
|
async def get_credential_from_params(request: Request):
|
@@ -66,7 +69,7 @@ async def get_current_user(
|
|
66
69
|
raise HTTPException(status_code=401, detail="Invalid token")
|
67
70
|
return user
|
68
71
|
|
69
|
-
app = FastAPI(docs_url=
|
72
|
+
app = FastAPI(docs_url=None, redoc_url=None, lifespan=lifespan)
|
70
73
|
app.add_middleware(
|
71
74
|
CORSMiddleware,
|
72
75
|
allow_origins=["*"],
|
@@ -75,9 +78,34 @@ app.add_middleware(
|
|
75
78
|
allow_headers=["*"],
|
76
79
|
)
|
77
80
|
|
81
|
+
@app.middleware("http")
|
82
|
+
async def log_requests(request: Request, call_next):
|
83
|
+
|
84
|
+
start_time = time.perf_counter()
|
85
|
+
response: Response = await call_next(request)
|
86
|
+
end_time = time.perf_counter()
|
87
|
+
response_time = end_time - start_time
|
88
|
+
response.headers["X-Response-Time"] = str(response_time)
|
89
|
+
|
90
|
+
if response.status_code >= 400:
|
91
|
+
logger_failed_request.error(f"{request.method} {request.url.path} {response.status_code}")
|
92
|
+
|
93
|
+
await req_conn.log_request(
|
94
|
+
request.method, request.url.path, response.status_code, response_time,
|
95
|
+
headers = dict(request.headers),
|
96
|
+
query = dict(request.query_params),
|
97
|
+
client = request.client,
|
98
|
+
request_size = int(request.headers.get("Content-Length", 0)),
|
99
|
+
response_size = int(response.headers.get("Content-Length", 0))
|
100
|
+
)
|
101
|
+
await req_conn.ensure_commit_once()
|
102
|
+
|
103
|
+
return response
|
104
|
+
|
78
105
|
router_fs = APIRouter(prefix="")
|
79
106
|
|
80
107
|
@router_fs.get("/{path:path}")
|
108
|
+
@handle_exception
|
81
109
|
async def get_file(path: str, download = False, user: UserRecord = Depends(get_current_user)):
|
82
110
|
path = ensure_uri_compnents(path)
|
83
111
|
|
@@ -130,6 +158,7 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
|
|
130
158
|
return await send(None, "inline")
|
131
159
|
|
132
160
|
@router_fs.put("/{path:path}")
|
161
|
+
@handle_exception
|
133
162
|
async def put_file(request: Request, path: str, user: UserRecord = Depends(get_current_user)):
|
134
163
|
path = ensure_uri_compnents(path)
|
135
164
|
if user.id == 0:
|
@@ -159,20 +188,20 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
|
|
159
188
|
logger.debug(f"Content-Type: {content_type}")
|
160
189
|
if content_type == "application/json":
|
161
190
|
body = await request.json()
|
162
|
-
await
|
191
|
+
await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'))
|
163
192
|
elif content_type == "application/x-www-form-urlencoded":
|
164
193
|
# may not work...
|
165
194
|
body = await request.form()
|
166
195
|
file = body.get("file")
|
167
196
|
if isinstance(file, str) or file is None:
|
168
197
|
raise HTTPException(status_code=400, detail="Invalid form data, file required")
|
169
|
-
await
|
198
|
+
await conn.save_file(user.id, path, await file.read())
|
170
199
|
elif content_type == "application/octet-stream":
|
171
200
|
body = await request.body()
|
172
|
-
await
|
201
|
+
await conn.save_file(user.id, path, body)
|
173
202
|
else:
|
174
203
|
body = await request.body()
|
175
|
-
await
|
204
|
+
await conn.save_file(user.id, path, body)
|
176
205
|
|
177
206
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
|
178
207
|
if exists_flag:
|
@@ -185,6 +214,7 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
|
|
185
214
|
}, content=json.dumps({"url": path}))
|
186
215
|
|
187
216
|
@router_fs.delete("/{path:path}")
|
217
|
+
@handle_exception
|
188
218
|
async def delete_file(path: str, user: UserRecord = Depends(get_current_user)):
|
189
219
|
path = ensure_uri_compnents(path)
|
190
220
|
if user.id == 0:
|
@@ -207,6 +237,7 @@ async def delete_file(path: str, user: UserRecord = Depends(get_current_user)):
|
|
207
237
|
router_api = APIRouter(prefix="/_api")
|
208
238
|
|
209
239
|
@router_api.get("/bundle")
|
240
|
+
@handle_exception
|
210
241
|
async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
|
211
242
|
logger.info(f"GET bundle({path}), user: {user.username}")
|
212
243
|
if user.id == 0:
|
@@ -249,6 +280,7 @@ async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
|
|
249
280
|
)
|
250
281
|
|
251
282
|
@router_api.get("/fmeta")
|
283
|
+
@handle_exception
|
252
284
|
async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user)):
|
253
285
|
logger.info(f"GET meta({path}), user: {user.username}")
|
254
286
|
if path.endswith("/"):
|
@@ -260,6 +292,7 @@ async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user))
|
|
260
292
|
return file_record
|
261
293
|
|
262
294
|
@router_api.post("/fmeta")
|
295
|
+
@handle_exception
|
263
296
|
async def update_file_meta(
|
264
297
|
path: str,
|
265
298
|
perm: Optional[int] = None,
|
@@ -280,7 +313,7 @@ async def update_file_meta(
|
|
280
313
|
|
281
314
|
if perm is not None:
|
282
315
|
logger.info(f"Update permission of {path} to {perm}")
|
283
|
-
await
|
316
|
+
await conn.file.set_file_record(
|
284
317
|
url = file_record.url,
|
285
318
|
permission = FileReadPermission(perm)
|
286
319
|
)
|
@@ -288,18 +321,18 @@ async def update_file_meta(
|
|
288
321
|
if new_path is not None:
|
289
322
|
new_path = ensure_uri_compnents(new_path)
|
290
323
|
logger.info(f"Update path of {path} to {new_path}")
|
291
|
-
await
|
324
|
+
await conn.move_file(path, new_path)
|
292
325
|
|
293
326
|
return Response(status_code=200, content="OK")
|
294
327
|
|
295
328
|
@router_api.get("/whoami")
|
329
|
+
@handle_exception
|
296
330
|
async def whoami(user: UserRecord = Depends(get_current_user)):
|
297
331
|
if user.id == 0:
|
298
332
|
raise HTTPException(status_code=401, detail="Login required")
|
299
333
|
user.credential = "__HIDDEN__"
|
300
334
|
return user
|
301
335
|
|
302
|
-
|
303
336
|
# order matters
|
304
337
|
app.include_router(router_api)
|
305
338
|
app.include_router(router_fs)
|
lfss/src/stat.py
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
from typing import Optional, Any
|
2
|
+
import aiosqlite
|
3
|
+
from .config import DATA_HOME
|
4
|
+
from .utils import debounce_async
|
5
|
+
|
6
|
+
class RequestDB:
|
7
|
+
conn: aiosqlite.Connection
|
8
|
+
def __init__(self):
|
9
|
+
self.db = DATA_HOME / 'requests.db'
|
10
|
+
|
11
|
+
async def init(self):
|
12
|
+
self.conn = await aiosqlite.connect(self.db)
|
13
|
+
await self.conn.execute('''
|
14
|
+
CREATE TABLE IF NOT EXISTS requests (
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
16
|
+
time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
17
|
+
method TEXT,
|
18
|
+
path TEXT,
|
19
|
+
headers TEXT,
|
20
|
+
query TEXT,
|
21
|
+
client TEXT,
|
22
|
+
duration REAL,
|
23
|
+
request_size INTEGER,
|
24
|
+
response_size INTEGER,
|
25
|
+
status INTEGER
|
26
|
+
)
|
27
|
+
''')
|
28
|
+
|
29
|
+
async def close(self):
|
30
|
+
await self.conn.close()
|
31
|
+
|
32
|
+
async def commit(self):
|
33
|
+
await self.conn.commit()
|
34
|
+
|
35
|
+
@debounce_async(0.1)
|
36
|
+
async def ensure_commit_once(self):
|
37
|
+
await self.commit()
|
38
|
+
|
39
|
+
async def __aenter__(self):
|
40
|
+
return self
|
41
|
+
|
42
|
+
async def __aexit__(self, exc_type, exc, tb):
|
43
|
+
await self.commit()
|
44
|
+
|
45
|
+
async def log_request(
|
46
|
+
self, method: str, path: str,
|
47
|
+
status: int, duration: float,
|
48
|
+
headers: Optional[Any] = None,
|
49
|
+
query: Optional[Any] = None,
|
50
|
+
client: Optional[Any] = None,
|
51
|
+
request_size: int = 0,
|
52
|
+
response_size: int = 0
|
53
|
+
) -> int:
|
54
|
+
method = str(method).upper()
|
55
|
+
headers = str(headers)
|
56
|
+
query = str(query)
|
57
|
+
client = str(client)
|
58
|
+
async with self.conn.execute('''
|
59
|
+
INSERT INTO requests (
|
60
|
+
method, path, headers, query, client, duration, request_size, response_size, status
|
61
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
62
|
+
''', (method, path, headers, query, client, duration, request_size, response_size, status)) as cursor:
|
63
|
+
assert cursor.lastrowid is not None
|
64
|
+
return cursor.lastrowid
|
65
|
+
|
lfss/src/utils.py
CHANGED
@@ -1,4 +1,7 @@
|
|
1
|
+
from typing import Callable
|
1
2
|
import urllib.parse
|
3
|
+
import asyncio
|
4
|
+
import functools
|
2
5
|
|
3
6
|
def encode_uri_compnents(path: str):
|
4
7
|
"""
|
@@ -21,4 +24,29 @@ def ensure_uri_compnents(path: str):
|
|
21
24
|
"""
|
22
25
|
Ensure the path components are safe to use
|
23
26
|
"""
|
24
|
-
return encode_uri_compnents(decode_uri_compnents(path))
|
27
|
+
return encode_uri_compnents(decode_uri_compnents(path))
|
28
|
+
|
29
|
+
def debounce_async(delay: float = 0):
|
30
|
+
"""
|
31
|
+
Decorator to debounce the async function (procedure)
|
32
|
+
The function must return None
|
33
|
+
"""
|
34
|
+
def debounce_wrap(func):
|
35
|
+
# https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancel
|
36
|
+
async def delayed_func(*args, **kwargs):
|
37
|
+
await asyncio.sleep(delay)
|
38
|
+
await func(*args, **kwargs)
|
39
|
+
|
40
|
+
task_record: asyncio.Task | None = None
|
41
|
+
@functools.wraps(func)
|
42
|
+
async def wrapper(*args, **kwargs):
|
43
|
+
nonlocal task_record
|
44
|
+
if task_record is not None:
|
45
|
+
task_record.cancel()
|
46
|
+
task_record = asyncio.create_task(delayed_func(*args, **kwargs))
|
47
|
+
try:
|
48
|
+
await task_record
|
49
|
+
except asyncio.CancelledError:
|
50
|
+
pass
|
51
|
+
return wrapper
|
52
|
+
return debounce_wrap
|
@@ -17,10 +17,11 @@ lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
17
|
lfss/src/config.py,sha256=mc8QoiWoPgiZ5JnSvKBH8jWbp5uLSyG3l5boDiQPXS0,325
|
18
18
|
lfss/src/database.py,sha256=jsDcDrc5r4mnuFiY10YEMwIRMvx6c4PY4LN9C9BinDA,27854
|
19
19
|
lfss/src/error.py,sha256=S5ui3tJ0uKX4EZnt2Db-KbDmJXlECI_OY95cNMkuegc,218
|
20
|
-
lfss/src/log.py,sha256=
|
21
|
-
lfss/src/server.py,sha256=
|
22
|
-
lfss/src/
|
23
|
-
lfss
|
24
|
-
lfss-0.3.
|
25
|
-
lfss-0.3.
|
26
|
-
lfss-0.3.
|
20
|
+
lfss/src/log.py,sha256=qNE04sHoZ2rMbQ5dR2zT7Xaz1KVAfYp5hKpWJX42S9g,5244
|
21
|
+
lfss/src/server.py,sha256=6sXOXGPF3s1h38zNS6mP4N9qEXZ8biEAx-wSdpCNlwg,13010
|
22
|
+
lfss/src/stat.py,sha256=Y28h-TNhQJzsAlCP4E7mnJmgISXGIECvCCRPq_73ZN0,2043
|
23
|
+
lfss/src/utils.py,sha256=qCXFTIcfnVsdg6zvoEDhZniLTU-WiG23SILXzUG1XBw,1613
|
24
|
+
lfss-0.3.2.dist-info/METADATA,sha256=qHYqmRzikIGgohOabShmfj1T9lQHa6iIPAF2BV2wSVo,1787
|
25
|
+
lfss-0.3.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
26
|
+
lfss-0.3.2.dist-info/entry_points.txt,sha256=nUIJhenyZbcymvPVhrzV7SAtRhd7O52DflbRrpQUC04,110
|
27
|
+
lfss-0.3.2.dist-info/RECORD,,
|
File without changes
|
File without changes
|