lfss 0.3.1__tar.gz → 0.3.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -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, '')
@@ -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
- elif isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
40
- elif isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
41
- elif isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
42
- elif isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
43
- elif isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
44
- else: raise HTTPException(status_code=500, detail=str(e))
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="/_docs", redoc_url=None, lifespan=lifespan)
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 handle_exception(conn.save_file)(user.id, path, json.dumps(body).encode('utf-8'))
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 handle_exception(conn.save_file)(user.id, path, await file.read())
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 handle_exception(conn.save_file)(user.id, path, body)
201
+ await conn.save_file(user.id, path, body)
173
202
  else:
174
203
  body = await request.body()
175
- await handle_exception(conn.save_file)(user.id, path, body)
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 handle_exception(conn.file.set_file_record)(
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 handle_exception(conn.move_file)(path, new_path)
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)
@@ -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
+
@@ -0,0 +1,52 @@
1
+ from typing import Callable
2
+ import urllib.parse
3
+ import asyncio
4
+ import functools
5
+
6
+ def encode_uri_compnents(path: str):
7
+ """
8
+ Encode the path components to encode the special characters,
9
+ also to avoid path traversal attack
10
+ """
11
+ path_sp = path.split("/")
12
+ mapped = map(lambda x: urllib.parse.quote(x), path_sp)
13
+ return "/".join(mapped)
14
+
15
+ def decode_uri_compnents(path: str):
16
+ """
17
+ Decode the path components to decode the special characters
18
+ """
19
+ path_sp = path.split("/")
20
+ mapped = map(lambda x: urllib.parse.unquote(x), path_sp)
21
+ return "/".join(mapped)
22
+
23
+ def ensure_uri_compnents(path: str):
24
+ """
25
+ Ensure the path components are safe to use
26
+ """
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "Lightweight file storage service"
5
5
  authors = ["li, mengxun <limengxun45@outlook.com>"]
6
6
  readme = "Readme.md"
@@ -1,24 +0,0 @@
1
- import urllib.parse
2
-
3
- def encode_uri_compnents(path: str):
4
- """
5
- Encode the path components to encode the special characters,
6
- also to avoid path traversal attack
7
- """
8
- path_sp = path.split("/")
9
- mapped = map(lambda x: urllib.parse.quote(x), path_sp)
10
- return "/".join(mapped)
11
-
12
- def decode_uri_compnents(path: str):
13
- """
14
- Decode the path components to decode the special characters
15
- """
16
- path_sp = path.split("/")
17
- mapped = map(lambda x: urllib.parse.unquote(x), path_sp)
18
- return "/".join(mapped)
19
-
20
- def ensure_uri_compnents(path: str):
21
- """
22
- Ensure the path components are safe to use
23
- """
24
- return encode_uri_compnents(decode_uri_compnents(path))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes