lfss 0.3.1__tar.gz → 0.4.0__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.4.0
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -69,7 +69,7 @@ class UserRecord:
69
69
  permission: 'FileReadPermission'
70
70
 
71
71
  def __str__(self):
72
- return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}), storage={self.max_storage}, permission={self.permission}"
72
+ return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}, storage={self.max_storage}, permission={self.permission})"
73
73
 
74
74
  DECOY_USER = UserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
75
75
  class UserConn(DBConnBase):
@@ -421,7 +421,7 @@ class FileConn(DBConnBase):
421
421
  new_exists = await self.get_file_record(new_url)
422
422
  if new_exists is not None:
423
423
  raise FileExistsError(f"File {new_url} already exists")
424
- async with self.conn.execute("UPDATE fmeta SET url = ? WHERE url = ?", (new_url, old_url)):
424
+ async with self.conn.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url)):
425
425
  self.logger.info(f"Moved file {old_url} to {new_url}")
426
426
 
427
427
  async def log_access(self, url: str):
@@ -481,8 +481,9 @@ class FileConn(DBConnBase):
481
481
  await self.conn.execute("DELETE FROM fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
482
482
 
483
483
  def validate_url(url: str, is_file = True):
484
- ret = not url.startswith('/') and not ('..' in url) and ('/' in url) and not ('//' in url) \
485
- and not ' ' in url and not url.startswith('\\') and not url.startswith('_') and not url.startswith('.')
484
+ prohibited_chars = ['..', ';', "'", '"', '\\', '\0', '\n', '\r', '\t', '\x0b', '\x0c']
485
+ ret = not url.startswith('/') and not url.startswith('_') and not url.startswith('.')
486
+ ret = ret and not any([c in url for c in prohibited_chars])
486
487
 
487
488
  if not ret:
488
489
  raise InvalidPathError(f"Invalid URL: {url}")
@@ -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
- from .utils import ensure_uri_compnents
18
+ from .utils import ensure_uri_compnents, format_last_modified, now_stamp
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,36 @@ 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
+ request_time_stamp = now_stamp()
85
+ start_time = time.perf_counter()
86
+ response: Response = await call_next(request)
87
+ end_time = time.perf_counter()
88
+ response_time = end_time - start_time
89
+ response.headers["X-Response-Time"] = str(response_time)
90
+
91
+ if response.status_code >= 400:
92
+ logger_failed_request.error(f"{request.method} {request.url.path} {response.status_code}")
93
+
94
+ await req_conn.log_request(
95
+ request_time_stamp,
96
+ request.method, request.url.path, response.status_code, response_time,
97
+ headers = dict(request.headers),
98
+ query = dict(request.query_params),
99
+ client = request.client,
100
+ request_size = int(request.headers.get("Content-Length", 0)),
101
+ response_size = int(response.headers.get("Content-Length", 0))
102
+ )
103
+ await req_conn.ensure_commit_once()
104
+
105
+ return response
106
+
78
107
  router_fs = APIRouter(prefix="")
79
108
 
80
109
  @router_fs.get("/{path:path}")
110
+ @handle_exception
81
111
  async def get_file(path: str, download = False, user: UserRecord = Depends(get_current_user)):
82
112
  path = ensure_uri_compnents(path)
83
113
 
@@ -120,7 +150,8 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
120
150
  return Response(
121
151
  content=fblob, media_type=media_type, headers={
122
152
  "Content-Disposition": f"{disposition}; filename={fname}",
123
- "Content-Length": str(len(fblob))
153
+ "Content-Length": str(len(fblob)),
154
+ "Last-Modified": format_last_modified(file_record.create_time)
124
155
  }
125
156
  )
126
157
 
@@ -130,6 +161,7 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
130
161
  return await send(None, "inline")
131
162
 
132
163
  @router_fs.put("/{path:path}")
164
+ @handle_exception
133
165
  async def put_file(request: Request, path: str, user: UserRecord = Depends(get_current_user)):
134
166
  path = ensure_uri_compnents(path)
135
167
  if user.id == 0:
@@ -159,20 +191,20 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
159
191
  logger.debug(f"Content-Type: {content_type}")
160
192
  if content_type == "application/json":
161
193
  body = await request.json()
162
- await handle_exception(conn.save_file)(user.id, path, json.dumps(body).encode('utf-8'))
194
+ await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'))
163
195
  elif content_type == "application/x-www-form-urlencoded":
164
196
  # may not work...
165
197
  body = await request.form()
166
198
  file = body.get("file")
167
199
  if isinstance(file, str) or file is None:
168
200
  raise HTTPException(status_code=400, detail="Invalid form data, file required")
169
- await handle_exception(conn.save_file)(user.id, path, await file.read())
201
+ await conn.save_file(user.id, path, await file.read())
170
202
  elif content_type == "application/octet-stream":
171
203
  body = await request.body()
172
- await handle_exception(conn.save_file)(user.id, path, body)
204
+ await conn.save_file(user.id, path, body)
173
205
  else:
174
206
  body = await request.body()
175
- await handle_exception(conn.save_file)(user.id, path, body)
207
+ await conn.save_file(user.id, path, body)
176
208
 
177
209
  # https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
178
210
  if exists_flag:
@@ -185,6 +217,7 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
185
217
  }, content=json.dumps({"url": path}))
186
218
 
187
219
  @router_fs.delete("/{path:path}")
220
+ @handle_exception
188
221
  async def delete_file(path: str, user: UserRecord = Depends(get_current_user)):
189
222
  path = ensure_uri_compnents(path)
190
223
  if user.id == 0:
@@ -207,6 +240,7 @@ async def delete_file(path: str, user: UserRecord = Depends(get_current_user)):
207
240
  router_api = APIRouter(prefix="/_api")
208
241
 
209
242
  @router_api.get("/bundle")
243
+ @handle_exception
210
244
  async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
211
245
  logger.info(f"GET bundle({path}), user: {user.username}")
212
246
  if user.id == 0:
@@ -249,6 +283,7 @@ async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
249
283
  )
250
284
 
251
285
  @router_api.get("/fmeta")
286
+ @handle_exception
252
287
  async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user)):
253
288
  logger.info(f"GET meta({path}), user: {user.username}")
254
289
  if path.endswith("/"):
@@ -260,6 +295,7 @@ async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user))
260
295
  return file_record
261
296
 
262
297
  @router_api.post("/fmeta")
298
+ @handle_exception
263
299
  async def update_file_meta(
264
300
  path: str,
265
301
  perm: Optional[int] = None,
@@ -280,7 +316,7 @@ async def update_file_meta(
280
316
 
281
317
  if perm is not None:
282
318
  logger.info(f"Update permission of {path} to {perm}")
283
- await handle_exception(conn.file.set_file_record)(
319
+ await conn.file.set_file_record(
284
320
  url = file_record.url,
285
321
  permission = FileReadPermission(perm)
286
322
  )
@@ -288,18 +324,18 @@ async def update_file_meta(
288
324
  if new_path is not None:
289
325
  new_path = ensure_uri_compnents(new_path)
290
326
  logger.info(f"Update path of {path} to {new_path}")
291
- await handle_exception(conn.move_file)(path, new_path)
327
+ await conn.move_file(path, new_path)
292
328
 
293
329
  return Response(status_code=200, content="OK")
294
330
 
295
331
  @router_api.get("/whoami")
332
+ @handle_exception
296
333
  async def whoami(user: UserRecord = Depends(get_current_user)):
297
334
  if user.id == 0:
298
335
  raise HTTPException(status_code=401, detail="Login required")
299
336
  user.credential = "__HIDDEN__"
300
337
  return user
301
338
 
302
-
303
339
  # order matters
304
340
  app.include_router(router_api)
305
341
  app.include_router(router_fs)
@@ -0,0 +1,66 @@
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 FLOAT DEFAULT (strftime('%s', 'now')),
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, time: float,
47
+ method: str, path: str,
48
+ status: int, duration: float,
49
+ headers: Optional[Any] = None,
50
+ query: Optional[Any] = None,
51
+ client: Optional[Any] = None,
52
+ request_size: int = 0,
53
+ response_size: int = 0
54
+ ) -> int:
55
+ method = str(method).upper()
56
+ headers = str(headers)
57
+ query = str(query)
58
+ client = str(client)
59
+ async with self.conn.execute('''
60
+ INSERT INTO requests (
61
+ time, method, path, headers, query, client, duration, request_size, response_size, status
62
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63
+ ''', (time, method, path, headers, query, client, duration, request_size, response_size, status)) as cursor:
64
+ assert cursor.lastrowid is not None
65
+ return cursor.lastrowid
66
+
@@ -0,0 +1,68 @@
1
+ import datetime
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
53
+
54
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
55
+ def format_last_modified(last_modified_gmt: str):
56
+ """
57
+ Format the last modified time to the HTTP standard format
58
+ - last_modified_gmt: The last modified time in SQLite ISO 8601 GMT format: e.g. '2021-09-01 12:00:00'
59
+ """
60
+ assert len(last_modified_gmt) == 19
61
+ dt = datetime.datetime.strptime(last_modified_gmt, '%Y-%m-%d %H:%M:%S')
62
+ return dt.strftime('%a, %d %b %Y %H:%M:%S GMT')
63
+
64
+ def now_stamp() -> float:
65
+ return datetime.datetime.now().timestamp()
66
+
67
+ def stamp_to_str(stamp: float) -> str:
68
+ return datetime.datetime.fromtimestamp(stamp).strftime('%Y-%m-%d %H:%M:%S')
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.3.1"
3
+ version = "0.4.0"
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