lfss 0.7.14__py3-none-any.whl → 0.8.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.
- Readme.md +2 -2
- docs/Permission.md +4 -2
- frontend/api.js +214 -8
- frontend/index.html +40 -28
- frontend/login.css +21 -0
- frontend/login.js +83 -0
- frontend/scripts.js +85 -111
- frontend/state.js +72 -0
- frontend/styles.css +26 -8
- frontend/thumb.css +6 -0
- frontend/thumb.js +39 -24
- lfss/{client → api}/__init__.py +52 -35
- lfss/{client/api.py → api/connector.py} +89 -8
- lfss/cli/cli.py +1 -1
- lfss/cli/user.py +1 -1
- lfss/src/connection_pool.py +3 -2
- lfss/src/database.py +158 -72
- lfss/src/datatype.py +8 -3
- lfss/src/error.py +3 -1
- lfss/src/server.py +67 -9
- lfss/src/stat.py +1 -1
- lfss/src/thumb.py +17 -12
- lfss/src/utils.py +47 -13
- {lfss-0.7.14.dist-info → lfss-0.8.0.dist-info}/METADATA +4 -3
- lfss-0.8.0.dist-info/RECORD +43 -0
- lfss-0.7.14.dist-info/RECORD +0 -40
- {lfss-0.7.14.dist-info → lfss-0.8.0.dist-info}/WHEEL +0 -0
- {lfss-0.7.14.dist-info → lfss-0.8.0.dist-info}/entry_points.txt +0 -0
lfss/src/database.py
CHANGED
@@ -10,10 +10,13 @@ import aiosqlite, aiofiles
|
|
10
10
|
import aiofiles.os
|
11
11
|
|
12
12
|
from .connection_pool import execute_sql, unique_cursor, transaction
|
13
|
-
from .datatype import
|
13
|
+
from .datatype import (
|
14
|
+
UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents,
|
15
|
+
FileSortKey, DirSortKey, isValidFileSortKey, isValidDirSortKey
|
16
|
+
)
|
14
17
|
from .config import LARGE_BLOB_DIR, CHUNK_SIZE
|
15
18
|
from .log import get_logger
|
16
|
-
from .utils import decode_uri_compnents, hash_credential, concurrent_wrap
|
19
|
+
from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async
|
17
20
|
from .error import *
|
18
21
|
|
19
22
|
class DBObjectBase(ABC):
|
@@ -156,55 +159,108 @@ class FileConn(DBObjectBase):
|
|
156
159
|
dirs = [await self.get_path_record(u) for u in dirnames] if not skim else [DirectoryRecord(u) for u in dirnames]
|
157
160
|
return dirs
|
158
161
|
|
159
|
-
async def
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
return PathContents([], files)
|
162
|
+
async def count_path_dirs(self, url: str):
|
163
|
+
if not url.endswith('/'): url += '/'
|
164
|
+
if url == '/': url = ''
|
165
|
+
cursor = await self.cur.execute("""
|
166
|
+
SELECT COUNT(*) FROM (
|
167
|
+
SELECT DISTINCT SUBSTR(
|
168
|
+
url, LENGTH(?) + 1,
|
169
|
+
INSTR(SUBSTR(url, LENGTH(?) + 1), '/')
|
170
|
+
) AS dirname
|
171
|
+
FROM fmeta WHERE url LIKE ? AND dirname != ''
|
172
|
+
)
|
173
|
+
""", (url, url, url + '%'))
|
174
|
+
res = await cursor.fetchone()
|
175
|
+
assert res is not None, "Error: count_path_dirs"
|
176
|
+
return res[0]
|
175
177
|
|
178
|
+
async def list_path_dirs(
|
179
|
+
self, url: str,
|
180
|
+
offset: int = 0, limit: int = int(1e5),
|
181
|
+
order_by: DirSortKey = '', order_desc: bool = False,
|
182
|
+
skim: bool = True
|
183
|
+
) -> list[DirectoryRecord]:
|
184
|
+
if not isValidDirSortKey(order_by):
|
185
|
+
raise ValueError(f"Invalid order_by ({order_by})")
|
186
|
+
|
187
|
+
if not url.endswith('/'): url += '/'
|
188
|
+
if url == '/': url = ''
|
189
|
+
|
190
|
+
sql_qury = """
|
191
|
+
SELECT DISTINCT SUBSTR(
|
192
|
+
url,
|
193
|
+
1 + LENGTH(?),
|
194
|
+
INSTR(SUBSTR(url, 1 + LENGTH(?)), '/')
|
195
|
+
) AS dirname
|
196
|
+
FROM fmeta WHERE url LIKE ? AND dirname != ''
|
197
|
+
""" \
|
198
|
+
+ (f"ORDER BY {order_by} {'DESC' if order_desc else 'ASC'}" if order_by else '') \
|
199
|
+
+ " LIMIT ? OFFSET ?"
|
200
|
+
cursor = await self.cur.execute(sql_qury, (url, url, url + '%', limit, offset))
|
201
|
+
res = await cursor.fetchall()
|
202
|
+
dirs_str = [r[0] for r in res]
|
203
|
+
async def get_dir(dir_url):
|
204
|
+
if skim:
|
205
|
+
return DirectoryRecord(dir_url)
|
176
206
|
else:
|
177
|
-
return
|
178
|
-
|
207
|
+
return await self.get_path_record(dir_url)
|
208
|
+
dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
|
209
|
+
return dirs
|
210
|
+
|
211
|
+
async def count_path_files(self, url: str, flat: bool = False):
|
212
|
+
if not url.endswith('/'): url += '/'
|
213
|
+
if url == '/': url = ''
|
179
214
|
if flat:
|
180
|
-
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', ))
|
181
|
-
|
182
|
-
|
183
|
-
|
215
|
+
cursor = await self.cur.execute("SELECT COUNT(*) FROM fmeta WHERE url LIKE ?", (url + '%', ))
|
216
|
+
else:
|
217
|
+
cursor = await self.cur.execute("SELECT COUNT(*) FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%'))
|
218
|
+
res = await cursor.fetchone()
|
219
|
+
assert res is not None, "Error: count_path_files"
|
220
|
+
return res[0]
|
184
221
|
|
185
|
-
|
222
|
+
async def list_path_files(
|
223
|
+
self, url: str,
|
224
|
+
offset: int = 0, limit: int = int(1e5),
|
225
|
+
order_by: FileSortKey = '', order_desc: bool = False,
|
226
|
+
flat: bool = False,
|
227
|
+
) -> list[FileRecord]:
|
228
|
+
if not isValidFileSortKey(order_by):
|
229
|
+
raise ValueError(f"Invalid order_by {order_by}")
|
230
|
+
|
231
|
+
if not url.endswith('/'): url += '/'
|
232
|
+
if url == '/': url = ''
|
233
|
+
|
234
|
+
sql_query = "SELECT * FROM fmeta WHERE url LIKE ?"
|
235
|
+
if not flat: sql_query += " AND url NOT LIKE ?"
|
236
|
+
if order_by: sql_query += f" ORDER BY {order_by} {'DESC' if order_desc else 'ASC'}"
|
237
|
+
sql_query += " LIMIT ? OFFSET ?"
|
238
|
+
if flat:
|
239
|
+
cursor = await self.cur.execute(sql_query, (url + '%', limit, offset))
|
240
|
+
else:
|
241
|
+
cursor = await self.cur.execute(sql_query, (url + '%', url + '%/%', limit, offset))
|
186
242
|
res = await cursor.fetchall()
|
187
243
|
files = [self.parse_record(r) for r in res]
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
244
|
+
return files
|
245
|
+
|
246
|
+
async def list_path(self, url: str) -> PathContents:
|
247
|
+
"""
|
248
|
+
List all files and directories under the given path.
|
249
|
+
This method is a handy way file browsing, but has limitaions:
|
250
|
+
- It does not support pagination
|
251
|
+
- It does not support sorting
|
252
|
+
- It cannot flatten directories
|
253
|
+
- It cannot list directories with details
|
254
|
+
"""
|
255
|
+
MAX_ITEMS = int(1e4)
|
256
|
+
dir_count = await self.count_path_dirs(url)
|
257
|
+
file_count = await self.count_path_files(url, flat=False)
|
258
|
+
if dir_count + file_count > MAX_ITEMS:
|
259
|
+
raise TooManyItemsError("Too many items, please paginate")
|
260
|
+
return PathContents(
|
261
|
+
dirs = await self.list_path_dirs(url, skim=True, limit=MAX_ITEMS),
|
262
|
+
files = await self.list_path_files(url, flat=False, limit=MAX_ITEMS)
|
201
263
|
)
|
202
|
-
res = await cursor.fetchall()
|
203
|
-
dirs_str = [r[0] + '/' for r in res if r[0] != '/']
|
204
|
-
async def get_dir(dir_url):
|
205
|
-
return DirectoryRecord(dir_url, -1)
|
206
|
-
dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
|
207
|
-
return PathContents(dirs, files)
|
208
264
|
|
209
265
|
async def get_path_record(self, url: str) -> DirectoryRecord:
|
210
266
|
"""
|
@@ -361,7 +417,8 @@ class FileConn(DBObjectBase):
|
|
361
417
|
async def set_file_blob(self, file_id: str, blob: bytes):
|
362
418
|
await self.cur.execute("INSERT OR REPLACE INTO blobs.fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
|
363
419
|
|
364
|
-
|
420
|
+
@staticmethod
|
421
|
+
async def set_file_blob_external(file_id: str, stream: AsyncIterable[bytes])->int:
|
365
422
|
size_sum = 0
|
366
423
|
try:
|
367
424
|
async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'wb') as f:
|
@@ -389,7 +446,8 @@ class FileConn(DBObjectBase):
|
|
389
446
|
if not chunk: break
|
390
447
|
yield chunk
|
391
448
|
|
392
|
-
|
449
|
+
@staticmethod
|
450
|
+
async def delete_file_blob_external(file_id: str):
|
393
451
|
if (LARGE_BLOB_DIR / file_id).exists():
|
394
452
|
await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
|
395
453
|
|
@@ -399,6 +457,36 @@ class FileConn(DBObjectBase):
|
|
399
457
|
async def delete_file_blobs(self, file_ids: list[str]):
|
400
458
|
await self.cur.execute("DELETE FROM blobs.fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
|
401
459
|
|
460
|
+
_log_active_queue = []
|
461
|
+
_log_active_lock = asyncio.Lock()
|
462
|
+
@debounce_async()
|
463
|
+
async def _set_all_active():
|
464
|
+
async with transaction() as conn:
|
465
|
+
uconn = UserConn(conn)
|
466
|
+
async with _log_active_lock:
|
467
|
+
for u in _log_active_queue:
|
468
|
+
await uconn.set_active(u)
|
469
|
+
_log_active_queue.clear()
|
470
|
+
async def delayed_log_activity(username: str):
|
471
|
+
async with _log_active_lock:
|
472
|
+
_log_active_queue.append(username)
|
473
|
+
await _set_all_active()
|
474
|
+
|
475
|
+
_log_access_queue = []
|
476
|
+
_log_access_lock = asyncio.Lock()
|
477
|
+
@debounce_async()
|
478
|
+
async def _log_all_access():
|
479
|
+
async with transaction() as conn:
|
480
|
+
fconn = FileConn(conn)
|
481
|
+
async with _log_access_lock:
|
482
|
+
for r in _log_access_queue:
|
483
|
+
await fconn.log_access(r)
|
484
|
+
_log_access_queue.clear()
|
485
|
+
async def delayed_log_access(url: str):
|
486
|
+
async with _log_access_lock:
|
487
|
+
_log_access_queue.append(url)
|
488
|
+
await _log_all_access()
|
489
|
+
|
402
490
|
def validate_url(url: str, is_file = True):
|
403
491
|
prohibited_chars = ['..', ';', "'", '"', '\\', '\0', '\n', '\r', '\t', '\x0b', '\x0c']
|
404
492
|
ret = not url.startswith('/') and not url.startswith('_') and not url.startswith('.')
|
@@ -433,11 +521,6 @@ class Database:
|
|
433
521
|
await execute_sql(conn, 'init.sql')
|
434
522
|
return self
|
435
523
|
|
436
|
-
async def record_user_activity(self, u: str):
|
437
|
-
async with transaction() as conn:
|
438
|
-
uconn = UserConn(conn)
|
439
|
-
await uconn.set_active(u)
|
440
|
-
|
441
524
|
async def update_file_record(self, user: UserRecord, url: str, permission: FileReadPermission):
|
442
525
|
validate_url(url)
|
443
526
|
async with transaction() as conn:
|
@@ -459,9 +542,7 @@ class Database:
|
|
459
542
|
if file_size is not provided, the blob must be bytes
|
460
543
|
"""
|
461
544
|
validate_url(url)
|
462
|
-
async with
|
463
|
-
uconn = UserConn(cur)
|
464
|
-
fconn = FileConn(cur)
|
545
|
+
async with unique_cursor() as cur:
|
465
546
|
user = await get_user(cur, u)
|
466
547
|
if user is None:
|
467
548
|
return
|
@@ -477,27 +558,35 @@ class Database:
|
|
477
558
|
if await get_user(cur, first_component) is None:
|
478
559
|
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
479
560
|
|
480
|
-
|
561
|
+
fconn_r = FileConn(cur)
|
562
|
+
user_size_used = await fconn_r.user_size(user.id)
|
563
|
+
|
481
564
|
if isinstance(blob, bytes):
|
482
565
|
file_size = len(blob)
|
483
566
|
if user_size_used + file_size > user.max_storage:
|
484
567
|
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
485
568
|
f_id = uuid.uuid4().hex
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
569
|
+
|
570
|
+
async with transaction() as w_cur:
|
571
|
+
fconn_w = FileConn(w_cur)
|
572
|
+
await fconn_w.set_file_blob(f_id, blob)
|
573
|
+
await fconn_w.set_file_record(
|
574
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
575
|
+
permission=permission, external=False, mime_type=mime_type)
|
490
576
|
else:
|
491
577
|
assert isinstance(blob, AsyncIterable)
|
492
578
|
f_id = uuid.uuid4().hex
|
493
|
-
file_size = await
|
579
|
+
file_size = await FileConn.set_file_blob_external(f_id, blob)
|
494
580
|
if user_size_used + file_size > user.max_storage:
|
495
|
-
await
|
581
|
+
await FileConn.delete_file_blob_external(f_id)
|
496
582
|
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
583
|
+
|
584
|
+
async with transaction() as w_cur:
|
585
|
+
await FileConn(w_cur).set_file_record(
|
586
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
587
|
+
permission=permission, external=True, mime_type=mime_type)
|
588
|
+
|
589
|
+
await delayed_log_activity(user.username)
|
501
590
|
|
502
591
|
async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
|
503
592
|
validate_url(url)
|
@@ -510,9 +599,7 @@ class Database:
|
|
510
599
|
raise ValueError(f"File {url} is not stored externally, should use read_file instead")
|
511
600
|
ret = fconn.get_file_blob_external(r.file_id)
|
512
601
|
|
513
|
-
|
514
|
-
await FileConn(w_cur).log_access(url)
|
515
|
-
|
602
|
+
await delayed_log_access(url)
|
516
603
|
return ret
|
517
604
|
|
518
605
|
|
@@ -532,9 +619,7 @@ class Database:
|
|
532
619
|
if blob is None:
|
533
620
|
raise FileNotFoundError(f"File {url} data not found")
|
534
621
|
|
535
|
-
|
536
|
-
await FileConn(w_cur).log_access(url)
|
537
|
-
|
622
|
+
await delayed_log_access(url)
|
538
623
|
return blob
|
539
624
|
|
540
625
|
async def delete_file(self, url: str, assure_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
|
@@ -653,7 +738,8 @@ class Database:
|
|
653
738
|
async with unique_cursor() as cur:
|
654
739
|
fconn = FileConn(cur)
|
655
740
|
if urls is None:
|
656
|
-
|
741
|
+
fcount = await fconn.count_path_files(top_url, flat=True)
|
742
|
+
urls = [r.url for r in (await fconn.list_path_files(top_url, flat=True, limit=fcount))]
|
657
743
|
|
658
744
|
for url in urls:
|
659
745
|
if not url.startswith(top_url):
|
lfss/src/datatype.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
from enum import IntEnum
|
2
|
+
from typing import Literal
|
2
3
|
import dataclasses
|
3
4
|
|
4
5
|
class FileReadPermission(IntEnum):
|
@@ -51,6 +52,10 @@ class DirectoryRecord:
|
|
51
52
|
|
52
53
|
@dataclasses.dataclass
|
53
54
|
class PathContents:
|
54
|
-
dirs: list[DirectoryRecord]
|
55
|
-
files: list[FileRecord]
|
56
|
-
|
55
|
+
dirs: list[DirectoryRecord] = dataclasses.field(default_factory=list)
|
56
|
+
files: list[FileRecord] = dataclasses.field(default_factory=list)
|
57
|
+
|
58
|
+
FileSortKey = Literal['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
|
59
|
+
isValidFileSortKey = lambda x: x in ['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
|
60
|
+
DirSortKey = Literal['', 'dirname']
|
61
|
+
isValidDirSortKey = lambda x: x in ['', 'dirname']
|
lfss/src/error.py
CHANGED
@@ -7,4 +7,6 @@ class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
|
|
7
7
|
|
8
8
|
class InvalidPathError(LFSSExceptionBase, ValueError):...
|
9
9
|
|
10
|
-
class StorageExceededError(LFSSExceptionBase):...
|
10
|
+
class StorageExceededError(LFSSExceptionBase):...
|
11
|
+
|
12
|
+
class TooManyItemsError(LFSSExceptionBase):...
|
lfss/src/server.py
CHANGED
@@ -16,9 +16,13 @@ from .error import *
|
|
16
16
|
from .log import get_logger
|
17
17
|
from .stat import RequestDB
|
18
18
|
from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_FILE_BYTES, CHUNK_SIZE
|
19
|
-
from .utils import ensure_uri_compnents, format_last_modified, now_stamp
|
19
|
+
from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
|
20
20
|
from .connection_pool import global_connection_init, global_connection_close, unique_cursor
|
21
|
-
from .database import Database,
|
21
|
+
from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn, delayed_log_activity
|
22
|
+
from .datatype import (
|
23
|
+
FileReadPermission, FileRecord, UserRecord, PathContents,
|
24
|
+
FileSortKey, DirSortKey
|
25
|
+
)
|
22
26
|
from .thumb import get_thumb
|
23
27
|
|
24
28
|
logger = get_logger("server", term_level="DEBUG")
|
@@ -35,6 +39,7 @@ async def lifespan(app: FastAPI):
|
|
35
39
|
yield
|
36
40
|
await req_conn.commit()
|
37
41
|
finally:
|
42
|
+
await wait_for_debounce_tasks()
|
38
43
|
await asyncio.gather(req_conn.close(), global_connection_close())
|
39
44
|
|
40
45
|
def handle_exception(fn):
|
@@ -49,6 +54,7 @@ def handle_exception(fn):
|
|
49
54
|
if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
|
50
55
|
if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
|
51
56
|
if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
|
57
|
+
if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
|
52
58
|
logger.error(f"Uncaptured error in {fn.__name__}: {e}")
|
53
59
|
raise
|
54
60
|
return wrapper
|
@@ -182,7 +188,7 @@ async def emit_file(
|
|
182
188
|
@handle_exception
|
183
189
|
async def get_file(
|
184
190
|
path: str,
|
185
|
-
download: bool = False,
|
191
|
+
download: bool = False, thumb: bool = False,
|
186
192
|
user: UserRecord = Depends(get_current_user)
|
187
193
|
):
|
188
194
|
path = ensure_uri_compnents(path)
|
@@ -199,8 +205,6 @@ async def get_file(
|
|
199
205
|
return await emit_thumbnail(path, download, create_time=None)
|
200
206
|
|
201
207
|
if path == "/":
|
202
|
-
if flat:
|
203
|
-
raise HTTPException(status_code=400, detail="Flat query not supported for root path")
|
204
208
|
return PathContents(
|
205
209
|
dirs = await fconn.list_root_dirs(user.username, skim=True) \
|
206
210
|
if not user.is_admin else await fconn.list_root_dirs(skim=True),
|
@@ -210,7 +214,7 @@ async def get_file(
|
|
210
214
|
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
211
215
|
raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
|
212
216
|
|
213
|
-
return await fconn.list_path(path
|
217
|
+
return await fconn.list_path(path)
|
214
218
|
|
215
219
|
# handle file query
|
216
220
|
async with unique_cursor() as conn:
|
@@ -332,7 +336,7 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
|
|
332
336
|
else:
|
333
337
|
res = await db.delete_file(path, user if not user.is_admin else None)
|
334
338
|
|
335
|
-
await
|
339
|
+
await delayed_log_activity(user.username)
|
336
340
|
if res:
|
337
341
|
return Response(status_code=200, content="Deleted")
|
338
342
|
else:
|
@@ -366,7 +370,10 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
|
|
366
370
|
|
367
371
|
async with unique_cursor() as conn:
|
368
372
|
fconn = FileConn(conn)
|
369
|
-
files =
|
373
|
+
files = await fconn.list_path_files(
|
374
|
+
url = path, flat = True,
|
375
|
+
limit=(await fconn.count_path_files(url = path, flat = True))
|
376
|
+
)
|
370
377
|
files = [f for f in files if await is_access_granted(f)]
|
371
378
|
if len(files) == 0:
|
372
379
|
raise HTTPException(status_code=404, detail="No files found")
|
@@ -421,7 +428,7 @@ async def update_file_meta(
|
|
421
428
|
path = ensure_uri_compnents(path)
|
422
429
|
if path.startswith("/"):
|
423
430
|
path = path[1:]
|
424
|
-
await
|
431
|
+
await delayed_log_activity(user.username)
|
425
432
|
|
426
433
|
# file
|
427
434
|
if not path.endswith("/"):
|
@@ -448,6 +455,57 @@ async def update_file_meta(
|
|
448
455
|
await db.move_path(user, path, new_path)
|
449
456
|
|
450
457
|
return Response(status_code=200, content="OK")
|
458
|
+
|
459
|
+
async def validate_path_permission(path: str, user: UserRecord):
|
460
|
+
if not path.endswith("/"):
|
461
|
+
raise HTTPException(status_code=400, detail="Path must end with /")
|
462
|
+
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
463
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
464
|
+
|
465
|
+
@router_api.get("/count-files")
|
466
|
+
async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
|
467
|
+
await validate_path_permission(path, user)
|
468
|
+
path = ensure_uri_compnents(path)
|
469
|
+
async with unique_cursor() as conn:
|
470
|
+
fconn = FileConn(conn)
|
471
|
+
return { "count": await fconn.count_path_files(url = path, flat = flat) }
|
472
|
+
@router_api.get("/list-files")
|
473
|
+
async def list_files(
|
474
|
+
path: str, offset: int = 0, limit: int = 1000,
|
475
|
+
order_by: FileSortKey = "", order_desc: bool = False,
|
476
|
+
flat: bool = False, user: UserRecord = Depends(registered_user)
|
477
|
+
):
|
478
|
+
await validate_path_permission(path, user)
|
479
|
+
path = ensure_uri_compnents(path)
|
480
|
+
async with unique_cursor() as conn:
|
481
|
+
fconn = FileConn(conn)
|
482
|
+
return await fconn.list_path_files(
|
483
|
+
url = path, offset = offset, limit = limit,
|
484
|
+
order_by=order_by, order_desc=order_desc,
|
485
|
+
flat=flat
|
486
|
+
)
|
487
|
+
|
488
|
+
@router_api.get("/count-dirs")
|
489
|
+
async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
|
490
|
+
await validate_path_permission(path, user)
|
491
|
+
path = ensure_uri_compnents(path)
|
492
|
+
async with unique_cursor() as conn:
|
493
|
+
fconn = FileConn(conn)
|
494
|
+
return { "count": await fconn.count_path_dirs(url = path) }
|
495
|
+
@router_api.get("/list-dirs")
|
496
|
+
async def list_dirs(
|
497
|
+
path: str, offset: int = 0, limit: int = 1000,
|
498
|
+
order_by: DirSortKey = "", order_desc: bool = False,
|
499
|
+
skim: bool = True, user: UserRecord = Depends(registered_user)
|
500
|
+
):
|
501
|
+
await validate_path_permission(path, user)
|
502
|
+
path = ensure_uri_compnents(path)
|
503
|
+
async with unique_cursor() as conn:
|
504
|
+
fconn = FileConn(conn)
|
505
|
+
return await fconn.list_path_dirs(
|
506
|
+
url = path, offset = offset, limit = limit,
|
507
|
+
order_by=order_by, order_desc=order_desc, skim=skim
|
508
|
+
)
|
451
509
|
|
452
510
|
@router_api.get("/whoami")
|
453
511
|
@handle_exception
|
lfss/src/stat.py
CHANGED
lfss/src/thumb.py
CHANGED
@@ -5,6 +5,7 @@ from typing import Optional
|
|
5
5
|
from PIL import Image
|
6
6
|
from io import BytesIO
|
7
7
|
import aiosqlite
|
8
|
+
from contextlib import asynccontextmanager
|
8
9
|
|
9
10
|
async def _maybe_init_thumb(c: aiosqlite.Cursor):
|
10
11
|
await c.execute('''
|
@@ -49,6 +50,13 @@ async def _delete_cache_thumb(c: aiosqlite.Cursor, path: str):
|
|
49
50
|
''', (path, ))
|
50
51
|
await c.execute('COMMIT')
|
51
52
|
|
53
|
+
@asynccontextmanager
|
54
|
+
async def cache_cursor():
|
55
|
+
async with aiosqlite.connect(THUMB_DB) as conn:
|
56
|
+
cur = await conn.cursor()
|
57
|
+
await _maybe_init_thumb(cur)
|
58
|
+
yield cur
|
59
|
+
|
52
60
|
async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
|
53
61
|
"""
|
54
62
|
returns [image bytes of thumbnail, mime type] if supported,
|
@@ -58,20 +66,17 @@ async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
|
|
58
66
|
if path.endswith('/'):
|
59
67
|
return None
|
60
68
|
|
61
|
-
async with
|
62
|
-
|
63
|
-
await
|
64
|
-
|
65
|
-
|
66
|
-
fconn = FileConn(main_c)
|
67
|
-
r = await fconn.get_file_record(path)
|
68
|
-
if r is None:
|
69
|
+
async with unique_cursor() as main_c:
|
70
|
+
fconn = FileConn(main_c)
|
71
|
+
r = await fconn.get_file_record(path)
|
72
|
+
if r is None:
|
73
|
+
async with cache_cursor() as cur:
|
69
74
|
await _delete_cache_thumb(cur, path)
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
return None
|
75
|
+
raise FileNotFoundError(f'File not found: {path}')
|
76
|
+
if not r.mime_type.startswith('image/'):
|
77
|
+
return None
|
74
78
|
|
79
|
+
async with cache_cursor() as cur:
|
75
80
|
c_time = r.create_time
|
76
81
|
thumb_blob = await _get_cache_thumb(cur, path, c_time)
|
77
82
|
if thumb_blob is not None:
|
lfss/src/utils.py
CHANGED
@@ -1,11 +1,14 @@
|
|
1
|
-
import datetime
|
1
|
+
import datetime, time
|
2
2
|
import urllib.parse
|
3
3
|
import asyncio
|
4
4
|
import functools
|
5
5
|
import hashlib
|
6
|
+
from asyncio import Lock
|
7
|
+
from collections import OrderedDict
|
6
8
|
from concurrent.futures import ThreadPoolExecutor
|
7
9
|
from typing import TypeVar, Callable, Awaitable
|
8
10
|
from functools import wraps, partial
|
11
|
+
from uuid import uuid4
|
9
12
|
import os
|
10
13
|
|
11
14
|
def hash_credential(username: str, password: str):
|
@@ -25,25 +28,56 @@ def ensure_uri_compnents(path: str):
|
|
25
28
|
""" Ensure the path components are safe to use """
|
26
29
|
return encode_uri_compnents(decode_uri_compnents(path))
|
27
30
|
|
28
|
-
|
29
|
-
|
31
|
+
g_debounce_tasks: OrderedDict[str, asyncio.Task] = OrderedDict()
|
32
|
+
lock_debounce_task_queue = Lock()
|
33
|
+
async def wait_for_debounce_tasks():
|
34
|
+
async def stop_task(task: asyncio.Task):
|
35
|
+
task.cancel()
|
36
|
+
try:
|
37
|
+
await task
|
38
|
+
except asyncio.CancelledError:
|
39
|
+
pass
|
40
|
+
await asyncio.gather(*map(stop_task, g_debounce_tasks.values()))
|
41
|
+
g_debounce_tasks.clear()
|
42
|
+
|
43
|
+
def debounce_async(delay: float = 0.1, max_wait: float = 1.):
|
44
|
+
"""
|
45
|
+
Debounce the async procedure,
|
46
|
+
ensuring execution at least once every `max_wait` seconds.
|
47
|
+
"""
|
30
48
|
def debounce_wrap(func):
|
31
|
-
|
49
|
+
task_record: tuple[str, asyncio.Task] | None = None
|
50
|
+
last_execution_time = 0
|
51
|
+
|
32
52
|
async def delayed_func(*args, **kwargs):
|
53
|
+
nonlocal last_execution_time
|
33
54
|
await asyncio.sleep(delay)
|
34
55
|
await func(*args, **kwargs)
|
56
|
+
last_execution_time = time.monotonic()
|
35
57
|
|
36
|
-
task_record: asyncio.Task | None = None
|
37
58
|
@functools.wraps(func)
|
38
59
|
async def wrapper(*args, **kwargs):
|
39
|
-
nonlocal task_record
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
60
|
+
nonlocal task_record, last_execution_time
|
61
|
+
|
62
|
+
async with lock_debounce_task_queue:
|
63
|
+
if task_record is not None:
|
64
|
+
task_record[1].cancel()
|
65
|
+
g_debounce_tasks.pop(task_record[0], None)
|
66
|
+
|
67
|
+
if time.monotonic() - last_execution_time > max_wait:
|
68
|
+
await func(*args, **kwargs)
|
69
|
+
last_execution_time = time.monotonic()
|
70
|
+
return
|
71
|
+
|
72
|
+
task = asyncio.create_task(delayed_func(*args, **kwargs))
|
73
|
+
task_uid = uuid4().hex
|
74
|
+
task_record = (task_uid, task)
|
75
|
+
async with lock_debounce_task_queue:
|
76
|
+
g_debounce_tasks[task_uid] = task
|
77
|
+
if len(g_debounce_tasks) > 2048:
|
78
|
+
# finished tasks are not removed from the dict
|
79
|
+
# so we need to clear it periodically
|
80
|
+
await wait_for_debounce_tasks()
|
47
81
|
return wrapper
|
48
82
|
return debounce_wrap
|
49
83
|
|