lfss 0.3.0__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.
frontend/index.html CHANGED
@@ -48,9 +48,11 @@
48
48
  <div class="input-group">
49
49
  <input type="file" id="file-selector" accept="*">
50
50
  <label for="file-name" id="upload-file-prefix"></label>
51
- <input type="text" id="file-name" placeholder="Destination file path" autocomplete="off">
52
- <span id='randomize-fname-btn'>🎲</span>
53
- <button id="upload-btn">Upload</button>
51
+ <div class="input-group">
52
+ <input type="text" id="file-name" placeholder="Destination file path" autocomplete="off">
53
+ <span id='randomize-fname-btn'>🎲</span>
54
+ <button id="upload-btn">Upload</button>
55
+ </div>
54
56
  </div>
55
57
  </div>
56
58
 
frontend/scripts.js CHANGED
@@ -264,7 +264,9 @@ function refreshFileList(){
264
264
 
265
265
  const downloadButton = document.createElement('a');
266
266
  downloadButton.textContent = 'Download';
267
- downloadButton.href = conn.config.endpoint + '/_api/bundle?path=' + dir.url + (dir.url.endsWith('/') ? '' : '/');
267
+ downloadButton.href = conn.config.endpoint + '/_api/bundle?' +
268
+ 'token=' + conn.config.token + '&' +
269
+ 'path=' + dir.url + (dir.url.endsWith('/') ? '' : '/');
268
270
  actContainer.appendChild(downloadButton);
269
271
 
270
272
  const deleteButton = document.createElement('a');
lfss/client/api.py CHANGED
@@ -3,7 +3,7 @@ import os
3
3
  import requests
4
4
  import urllib.parse
5
5
  from lfss.src.database import (
6
- FileReadPermission, FileDBRecord, DBUserRecord, PathContents
6
+ FileReadPermission, FileRecord, UserRecord, PathContents
7
7
  )
8
8
 
9
9
  _default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
@@ -58,11 +58,11 @@ class Connector:
58
58
  path = path[1:]
59
59
  self._fetch('DELETE', path)()
60
60
 
61
- def get_metadata(self, path: str) -> Optional[FileDBRecord]:
61
+ def get_metadata(self, path: str) -> Optional[FileRecord]:
62
62
  """Gets the metadata for the file at the specified path."""
63
63
  try:
64
64
  response = self._fetch('GET', '_api/fmeta', {'path': path})()
65
- return FileDBRecord(**response.json())
65
+ return FileRecord(**response.json())
66
66
  except requests.exceptions.HTTPError as e:
67
67
  if e.response.status_code == 404:
68
68
  return None
@@ -85,7 +85,7 @@ class Connector:
85
85
  headers = {'Content-Type': 'application/www-form-urlencoded'}
86
86
  )
87
87
 
88
- def whoami(self) -> DBUserRecord:
88
+ def whoami(self) -> UserRecord:
89
89
  """Gets information about the current user."""
90
90
  response = self._fetch('GET', '_api/whoami')()
91
- return DBUserRecord(**response.json())
91
+ return UserRecord(**response.json())
lfss/src/database.py CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- from typing import Optional, overload, Literal
2
+ from typing import Optional, overload, Literal, AsyncIterable
3
3
  from abc import ABC, abstractmethod
4
4
 
5
5
  import urllib.parse
@@ -58,7 +58,7 @@ class FileReadPermission(IntEnum):
58
58
  PRIVATE = 3 # accessible by owner only (including admin)
59
59
 
60
60
  @dataclasses.dataclass
61
- class DBUserRecord:
61
+ class UserRecord:
62
62
  id: int
63
63
  username: str
64
64
  credential: str
@@ -71,12 +71,12 @@ class DBUserRecord:
71
71
  def __str__(self):
72
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
- DECOY_USER = DBUserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
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):
76
76
 
77
77
  @staticmethod
78
- def parse_record(record) -> DBUserRecord:
79
- return DBUserRecord(*record)
78
+ def parse_record(record) -> UserRecord:
79
+ return UserRecord(*record)
80
80
 
81
81
  async def init(self):
82
82
  await super().init()
@@ -102,21 +102,21 @@ class UserConn(DBConnBase):
102
102
 
103
103
  return self
104
104
 
105
- async def get_user(self, username: str) -> Optional[DBUserRecord]:
105
+ async def get_user(self, username: str) -> Optional[UserRecord]:
106
106
  async with self.conn.execute("SELECT * FROM user WHERE username = ?", (username, )) as cursor:
107
107
  res = await cursor.fetchone()
108
108
 
109
109
  if res is None: return None
110
110
  return self.parse_record(res)
111
111
 
112
- async def get_user_by_id(self, user_id: int) -> Optional[DBUserRecord]:
112
+ async def get_user_by_id(self, user_id: int) -> Optional[UserRecord]:
113
113
  async with self.conn.execute("SELECT * FROM user WHERE id = ?", (user_id, )) as cursor:
114
114
  res = await cursor.fetchone()
115
115
 
116
116
  if res is None: return None
117
117
  return self.parse_record(res)
118
118
 
119
- async def get_user_by_credential(self, credential: str) -> Optional[DBUserRecord]:
119
+ async def get_user_by_credential(self, credential: str) -> Optional[UserRecord]:
120
120
  async with self.conn.execute("SELECT * FROM user WHERE credential = ?", (credential, )) as cursor:
121
121
  res = await cursor.fetchone()
122
122
 
@@ -182,7 +182,7 @@ class UserConn(DBConnBase):
182
182
  self.logger.info(f"Delete user {username}")
183
183
 
184
184
  @dataclasses.dataclass
185
- class FileDBRecord:
185
+ class FileRecord:
186
186
  url: str
187
187
  owner_id: int
188
188
  file_id: str # defines mapping from fmata to fdata
@@ -206,13 +206,13 @@ class DirectoryRecord:
206
206
  @dataclasses.dataclass
207
207
  class PathContents:
208
208
  dirs: list[DirectoryRecord]
209
- files: list[FileDBRecord]
209
+ files: list[FileRecord]
210
210
 
211
211
  class FileConn(DBConnBase):
212
212
 
213
213
  @staticmethod
214
- def parse_record(record) -> FileDBRecord:
215
- return FileDBRecord(*record)
214
+ def parse_record(record) -> FileRecord:
215
+ return FileRecord(*record)
216
216
 
217
217
  async def init(self):
218
218
  await super().init()
@@ -259,26 +259,26 @@ class FileConn(DBConnBase):
259
259
 
260
260
  return self
261
261
 
262
- async def get_file_record(self, url: str) -> Optional[FileDBRecord]:
262
+ async def get_file_record(self, url: str) -> Optional[FileRecord]:
263
263
  async with self.conn.execute("SELECT * FROM fmeta WHERE url = ?", (url, )) as cursor:
264
264
  res = await cursor.fetchone()
265
265
  if res is None:
266
266
  return None
267
267
  return self.parse_record(res)
268
268
 
269
- async def get_file_records(self, urls: list[str]) -> list[FileDBRecord]:
269
+ async def get_file_records(self, urls: list[str]) -> list[FileRecord]:
270
270
  async with self.conn.execute("SELECT * FROM fmeta WHERE url IN ({})".format(','.join(['?'] * len(urls))), urls) as cursor:
271
271
  res = await cursor.fetchall()
272
272
  if res is None:
273
273
  return []
274
274
  return [self.parse_record(r) for r in res]
275
275
 
276
- async def get_user_file_records(self, owner_id: int) -> list[FileDBRecord]:
276
+ async def get_user_file_records(self, owner_id: int) -> list[FileRecord]:
277
277
  async with self.conn.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, )) as cursor:
278
278
  res = await cursor.fetchall()
279
279
  return [self.parse_record(r) for r in res]
280
280
 
281
- async def get_path_records(self, url: str) -> list[FileDBRecord]:
281
+ async def get_path_records(self, url: str) -> list[FileRecord]:
282
282
  async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
283
283
  res = await cursor.fetchall()
284
284
  return [self.parse_record(r) for r in res]
@@ -301,11 +301,11 @@ class FileConn(DBConnBase):
301
301
  return dirs
302
302
 
303
303
  @overload
304
- async def list_path(self, url: str, flat: Literal[True]) -> list[FileDBRecord]:...
304
+ async def list_path(self, url: str, flat: Literal[True]) -> list[FileRecord]:...
305
305
  @overload
306
306
  async def list_path(self, url: str, flat: Literal[False]) -> PathContents:...
307
307
 
308
- async def list_path(self, url: str, flat: bool = False) -> list[FileDBRecord] | PathContents:
308
+ async def list_path(self, url: str, flat: bool = False) -> list[FileRecord] | PathContents:
309
309
  """
310
310
  List all files and directories under the given path,
311
311
  if flat is True, return a list of FileDBRecord, recursively including all subdirectories.
@@ -495,7 +495,7 @@ def validate_url(url: str, is_file = True):
495
495
  if not ret:
496
496
  raise InvalidPathError(f"Invalid URL: {url}")
497
497
 
498
- async def get_user(db: "Database", user: int | str) -> Optional[DBUserRecord]:
498
+ async def get_user(db: "Database", user: int | str) -> Optional[UserRecord]:
499
499
  if isinstance(user, str):
500
500
  return await db.user.get_user(user)
501
501
  elif isinstance(user, int):
@@ -591,7 +591,7 @@ class Database:
591
591
 
592
592
  return blob
593
593
 
594
- async def delete_file(self, url: str) -> Optional[FileDBRecord]:
594
+ async def delete_file(self, url: str) -> Optional[FileRecord]:
595
595
  validate_url(url)
596
596
 
597
597
  async with transaction(self):
@@ -631,32 +631,37 @@ class Database:
631
631
  await self.file.delete_file_blobs([r.file_id for r in records])
632
632
  await self.file.delete_user_file_records(user.id)
633
633
  await self.user.delete_user(user.username)
634
-
635
- async def zip_path(self, top_url: str, urls: Optional[list[str]]) -> io.BytesIO:
634
+
635
+ async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes]]:
636
636
  if urls is None:
637
637
  urls = [r.url for r in await self.file.list_path(top_url, flat=True)]
638
638
 
639
+ for url in urls:
640
+ if not url.startswith(top_url):
641
+ continue
642
+ r = await self.file.get_file_record(url)
643
+ if r is None:
644
+ continue
645
+ f_id = r.file_id
646
+ blob = await self.file.get_file_blob(f_id)
647
+ if blob is None:
648
+ self.logger.warning(f"Blob not found for {url}")
649
+ continue
650
+ yield r, blob
651
+
652
+ async def zip_path(self, top_url: str, urls: Optional[list[str]]) -> io.BytesIO:
653
+ if top_url.startswith('/'):
654
+ top_url = top_url[1:]
639
655
  buffer = io.BytesIO()
640
656
  with zipfile.ZipFile(buffer, 'w') as zf:
641
- for url in urls:
642
- if not url.startswith(top_url):
643
- continue
644
- r = await self.file.get_file_record(url)
645
- if r is None:
646
- continue
647
- f_id = r.file_id
648
- blob = await self.file.get_file_blob(f_id)
649
- if blob is None:
650
- continue
651
-
652
- rel_path = url[len(top_url):]
657
+ async for (r, blob) in self.iter_path(top_url, urls):
658
+ rel_path = r.url[len(top_url):]
653
659
  rel_path = decode_uri_compnents(rel_path)
654
660
  zf.writestr(rel_path, blob)
655
-
656
661
  buffer.seek(0)
657
662
  return buffer
658
663
 
659
- def check_user_permission(user: DBUserRecord, owner: DBUserRecord, file: FileDBRecord) -> tuple[bool, str]:
664
+ def check_user_permission(user: UserRecord, owner: UserRecord, file: FileRecord) -> tuple[bool, str]:
660
665
  if user.is_admin:
661
666
  return True, ""
662
667
 
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
- from .database import Database, DBUserRecord, DECOY_USER, FileDBRecord, check_user_permission, FileReadPermission
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,10 +78,35 @@ 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}")
81
- async def get_file(path: str, download = False, user: DBUserRecord = Depends(get_current_user)):
108
+ @handle_exception
109
+ async def get_file(path: str, download = False, user: UserRecord = Depends(get_current_user)):
82
110
  path = ensure_uri_compnents(path)
83
111
 
84
112
  # handle directory query
@@ -130,7 +158,8 @@ async def get_file(path: str, download = False, user: DBUserRecord = Depends(get
130
158
  return await send(None, "inline")
131
159
 
132
160
  @router_fs.put("/{path:path}")
133
- async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get_current_user)):
161
+ @handle_exception
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:
136
165
  logger.debug("Reject put request from DECOY_USER")
@@ -159,20 +188,20 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
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,7 +214,8 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
185
214
  }, content=json.dumps({"url": path}))
186
215
 
187
216
  @router_fs.delete("/{path:path}")
188
- async def delete_file(path: str, user: DBUserRecord = Depends(get_current_user)):
217
+ @handle_exception
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:
191
221
  raise HTTPException(status_code=401, detail="Permission denied")
@@ -207,7 +237,8 @@ async def delete_file(path: str, user: DBUserRecord = Depends(get_current_user))
207
237
  router_api = APIRouter(prefix="/_api")
208
238
 
209
239
  @router_api.get("/bundle")
210
- async def bundle_files(path: str, user: DBUserRecord = Depends(get_current_user)):
240
+ @handle_exception
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:
213
244
  raise HTTPException(status_code=401, detail="Permission denied")
@@ -218,7 +249,7 @@ async def bundle_files(path: str, user: DBUserRecord = Depends(get_current_user)
218
249
  path = path[1:]
219
250
 
220
251
  owner_records_cache = {} # cache owner records, ID -> UserRecord
221
- async def is_access_granted(file_record: FileDBRecord):
252
+ async def is_access_granted(file_record: FileRecord):
222
253
  owner_id = file_record.owner_id
223
254
  owner = owner_records_cache.get(owner_id, None)
224
255
  if owner is None:
@@ -249,7 +280,8 @@ async def bundle_files(path: str, user: DBUserRecord = Depends(get_current_user)
249
280
  )
250
281
 
251
282
  @router_api.get("/fmeta")
252
- async def get_file_meta(path: str, user: DBUserRecord = Depends(get_current_user)):
283
+ @handle_exception
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("/"):
255
287
  raise HTTPException(status_code=400, detail="Invalid path")
@@ -260,11 +292,12 @@ async def get_file_meta(path: str, user: DBUserRecord = 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,
266
299
  new_path: Optional[str] = None,
267
- user: DBUserRecord = Depends(get_current_user)
300
+ user: UserRecord = Depends(get_current_user)
268
301
  ):
269
302
  if user.id == 0:
270
303
  raise HTTPException(status_code=401, detail="Permission denied")
@@ -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")
296
- async def whoami(user: DBUserRecord = Depends(get_current_user)):
329
+ @handle_exception
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.3.0
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
@@ -2,25 +2,26 @@ Readme.md,sha256=8ZAADV1K20gEDhwiL-Qq_rBAePlwWQn8c5uJ_X7OCIg,1128
2
2
  docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
3
  docs/Permission.md,sha256=EY1Y4tT5PDdHDb2pXsVgMAJXxkUihTZfPT0_z7Q53FA,1485
4
4
  frontend/api.js,sha256=D_MaQlmBGzzSR30a23Kh3DxPeF8MpKYe5uXSb9srRTU,7281
5
- frontend/index.html,sha256=JP6Sd-1JdlEfWQ4fjmSs-CrNw-2iq1RlS55SuXJq5lg,2019
5
+ frontend/index.html,sha256=VPJDs2LG8ep9kjlsKzjWzpN9vc1VGgdvOUlNTZWyQoQ,2088
6
6
  frontend/popup.css,sha256=VzkjG1ZTLxhHMtTyobnlvqYmVsTmdbJJed2Pu1cc06c,1007
7
7
  frontend/popup.js,sha256=dH5n7C2Vo9gCsMfQ4ajL4D1ETl0Wk9rIldxUb7x0f_c,4634
8
- frontend/scripts.js,sha256=lp5EalD0Ikpy4Tw5dhEORsOB_44Z88I4gJI4e8C1SDE,18175
8
+ frontend/scripts.js,sha256=1Cd5cb84SWolozA0Q1MpWaXiAqeQ5R6qZyk7gdoRPgU,18266
9
9
  frontend/styles.css,sha256=Ql_-W5dbh6LlJL2Kez8qsqPSF2fckFgmOYP4SMfKJVs,4065
10
10
  frontend/utils.js,sha256=biE2te5ezswZyuwlDTYbHEt7VWKfCcUrusNt1lHjkLw,2263
11
11
  lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
12
12
  lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
13
13
  lfss/cli/user.py,sha256=906MIQO1mr4XXzPpT8Kfri1G61bWlgjDzIglxypQ7z0,3251
14
14
  lfss/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- lfss/client/api.py,sha256=SSrs1rafALmK_Pc7MfqeQm0El1rcGc-dXP8H6XMpmrY,3455
15
+ lfss/client/api.py,sha256=VBiU-JxGaN6S-fXYD_KvzG7FsGBIUzZ5IisJJSMooDk,3443
16
16
  lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  lfss/src/config.py,sha256=mc8QoiWoPgiZ5JnSvKBH8jWbp5uLSyG3l5boDiQPXS0,325
18
- lfss/src/database.py,sha256=6LDXaYO_dFuR8KrOPcvXS1_-sszFwvpyhbXaS2MTpq4,27576
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=7mRHFwhx7GKtm_cRryoEIlRQhHTLQC3Qd-N81YsoKao,5174
21
- lfss/src/server.py,sha256=BeJ15QxN66k7UETCkUe03NDIIKWYdGYRtOMjox_CxIQ,11872
22
- lfss/src/utils.py,sha256=MrjKc8W2Y7AbgVGadSNAA50tRMbGYWRrA4KUhOCwuUU,694
23
- lfss-0.3.0.dist-info/METADATA,sha256=xTlFyG26uh5X1Vk0PDGj6tPW_6ExBgMEQ3AK3SCyYLQ,1787
24
- lfss-0.3.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
25
- lfss-0.3.0.dist-info/entry_points.txt,sha256=nUIJhenyZbcymvPVhrzV7SAtRhd7O52DflbRrpQUC04,110
26
- lfss-0.3.0.dist-info/RECORD,,
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