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 +5 -3
- frontend/scripts.js +3 -1
- lfss/client/api.py +5 -5
- lfss/src/database.py +41 -36
- lfss/src/log.py +2 -0
- lfss/src/server.py +62 -29
- lfss/src/stat.py +65 -0
- lfss/src/utils.py +29 -1
- {lfss-0.3.0.dist-info → lfss-0.3.2.dist-info}/METADATA +1 -1
- {lfss-0.3.0.dist-info → lfss-0.3.2.dist-info}/RECORD +12 -11
- {lfss-0.3.0.dist-info → lfss-0.3.2.dist-info}/WHEEL +0 -0
- {lfss-0.3.0.dist-info → lfss-0.3.2.dist-info}/entry_points.txt +0 -0
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
|
-
<
|
52
|
-
|
53
|
-
|
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?
|
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,
|
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[
|
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
|
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) ->
|
88
|
+
def whoami(self) -> UserRecord:
|
89
89
|
"""Gets information about the current user."""
|
90
90
|
response = self._fetch('GET', '_api/whoami')()
|
91
|
-
return
|
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
|
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 =
|
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) ->
|
79
|
-
return
|
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[
|
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[
|
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[
|
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
|
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[
|
209
|
+
files: list[FileRecord]
|
210
210
|
|
211
211
|
class FileConn(DBConnBase):
|
212
212
|
|
213
213
|
@staticmethod
|
214
|
-
def parse_record(record) ->
|
215
|
-
return
|
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[
|
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[
|
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[
|
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[
|
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[
|
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[
|
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[
|
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[
|
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
|
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
|
642
|
-
|
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:
|
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,
|
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,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
|
-
|
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
|
-
|
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
|
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,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
|
-
|
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
|
-
|
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:
|
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
|
-
|
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:
|
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
|
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")
|
296
|
-
|
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
|
@@ -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=
|
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=
|
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=
|
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=
|
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
|