lfss 0.3.1__py3-none-any.whl → 0.4.0__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/database.py +5 -4
- lfss/src/log.py +2 -0
- lfss/src/server.py +58 -22
- lfss/src/stat.py +66 -0
- lfss/src/utils.py +45 -1
- {lfss-0.3.1.dist-info → lfss-0.4.0.dist-info}/METADATA +1 -1
- {lfss-0.3.1.dist-info → lfss-0.4.0.dist-info}/RECORD +9 -8
- {lfss-0.3.1.dist-info → lfss-0.4.0.dist-info}/WHEEL +0 -0
- {lfss-0.3.1.dist-info → lfss-0.4.0.dist-info}/entry_points.txt +0 -0
lfss/src/database.py
CHANGED
@@ -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}
|
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 =
|
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
|
-
|
485
|
-
|
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}")
|
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
|
-
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
|
-
|
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,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
|
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
|
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
|
204
|
+
await conn.save_file(user.id, path, body)
|
173
205
|
else:
|
174
206
|
body = await request.body()
|
175
|
-
await
|
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
|
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
|
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)
|
lfss/src/stat.py
ADDED
@@ -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
|
+
|
lfss/src/utils.py
CHANGED
@@ -1,4 +1,7 @@
|
|
1
|
+
import datetime
|
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,45 @@ 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
|
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')
|
@@ -15,12 +15,13 @@ lfss/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
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=wNDsvjvr7Jq1gDeUd_26tETcfeiplAmywIwXLK1TD80,27923
|
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.
|
25
|
-
lfss-0.
|
26
|
-
lfss-0.
|
20
|
+
lfss/src/log.py,sha256=qNE04sHoZ2rMbQ5dR2zT7Xaz1KVAfYp5hKpWJX42S9g,5244
|
21
|
+
lfss/src/server.py,sha256=P4GpsIJ869fi6lzHk2IVMyJooGWIvqh0cykkDerJ74k,13190
|
22
|
+
lfss/src/stat.py,sha256=_4OaSvBm7D6mPgifwxnhGIEk1_q3SxfJr3lizaEoV_w,2081
|
23
|
+
lfss/src/utils.py,sha256=8VkrtpSmurbMiX7GyK-n7Grvzy3uwSJXHdONEsuLCDI,2272
|
24
|
+
lfss-0.4.0.dist-info/METADATA,sha256=X0qXQ4gkl_sJ5VNEGLJ0XG0JSqmNnOPixHVn6ZQ7pj0,1787
|
25
|
+
lfss-0.4.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
26
|
+
lfss-0.4.0.dist-info/entry_points.txt,sha256=nUIJhenyZbcymvPVhrzV7SAtRhd7O52DflbRrpQUC04,110
|
27
|
+
lfss-0.4.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|