lfss 0.9.1__py3-none-any.whl → 0.9.4__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 +4 -2
- docs/Changelog.md +27 -0
- docs/Enviroment_variables.md +12 -0
- docs/Known_issues.md +3 -1
- docs/Webdav.md +3 -3
- frontend/api.js +21 -0
- frontend/scripts.js +49 -2
- lfss/api/connector.py +6 -0
- lfss/eng/connection_pool.py +1 -1
- lfss/eng/database.py +13 -16
- lfss/eng/error.py +6 -2
- lfss/eng/thumb.py +5 -1
- lfss/eng/utils.py +46 -24
- lfss/svc/app.py +2 -2
- lfss/svc/app_base.py +10 -8
- lfss/svc/app_dav.py +114 -96
- lfss/svc/app_native.py +15 -9
- lfss/svc/common_impl.py +98 -31
- {lfss-0.9.1.dist-info → lfss-0.9.4.dist-info}/METADATA +5 -3
- {lfss-0.9.1.dist-info → lfss-0.9.4.dist-info}/RECORD +22 -20
- {lfss-0.9.1.dist-info → lfss-0.9.4.dist-info}/WHEEL +0 -0
- {lfss-0.9.1.dist-info → lfss-0.9.4.dist-info}/entry_points.txt +0 -0
Readme.md
CHANGED
@@ -34,10 +34,12 @@ The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/ur
|
|
34
34
|
The authentication can be acheived through one of the following methods:
|
35
35
|
1. `Authorization` header with the value `Bearer sha256(<username><password>)`.
|
36
36
|
2. `token` query parameter with the value `sha256(<username><password>)`.
|
37
|
-
3. HTTP Basic Authentication with the username and password.
|
37
|
+
3. HTTP Basic Authentication with the username and password (If WebDAV is enabled).
|
38
38
|
|
39
39
|
You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
|
40
40
|
|
41
41
|
By default, the service exposes all files to the public for `GET` requests,
|
42
42
|
but file-listing is restricted to the user's own files.
|
43
|
-
Please refer to [docs/Permission.md](./docs/Permission.md) for more details on the permission system.
|
43
|
+
Please refer to [docs/Permission.md](./docs/Permission.md) for more details on the permission system.
|
44
|
+
|
45
|
+
More can be found in the [docs](./docs) directory.
|
docs/Changelog.md
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
|
2
|
+
## 0.9
|
3
|
+
|
4
|
+
### 0.9.4
|
5
|
+
- Decode WebDAV file name.
|
6
|
+
- Allow root-listing for WebDAV.
|
7
|
+
- Always return 207 status code for propfind.
|
8
|
+
- Refactor debounce utility.
|
9
|
+
|
10
|
+
### 0.9.3
|
11
|
+
- Fix empty file getting.
|
12
|
+
- HTTP `PUT/POST` default to overwrite the file.
|
13
|
+
- Use shared implementations for `PUT`, `GET`, `DELETE` methods.
|
14
|
+
- Inherit permission on overwriting `unset` permission files.
|
15
|
+
|
16
|
+
### 0.9.2
|
17
|
+
- Native copy function.
|
18
|
+
- Only enable basic authentication if WebDAV is enabled.
|
19
|
+
- `WWW-Authenticate` header is now added to the response when authentication fails.
|
20
|
+
|
21
|
+
### 0.9.1
|
22
|
+
- Add WebDAV support.
|
23
|
+
- Code refactor, use `lfss.eng` and `lfss.svc`.
|
24
|
+
|
25
|
+
### 0.9.0
|
26
|
+
- User peer access control, now user can share their path with other users.
|
27
|
+
- Fix high concurrency database locking on file getting.
|
@@ -0,0 +1,12 @@
|
|
1
|
+
|
2
|
+
# Enviroment variables
|
3
|
+
|
4
|
+
**Server**
|
5
|
+
- `LFSS_DATA`: The directory to store the data. Default is `.storage_data`.
|
6
|
+
- `LFSS_WEBDAV`: Enable WebDAV support. Default is `0`, set to `1` to enable.
|
7
|
+
- `LFSS_LARGE_FILE`: The size limit of the file to store in the database. Default is `8m`.
|
8
|
+
- `LFSS_DEBUG`: Enable debug mode for more verbose logging. Default is `0`, set to `1` to enable.
|
9
|
+
|
10
|
+
**Client**
|
11
|
+
- `LFSS_ENDPOINT`: The fallback server endpoint. Default is `http://localhost:8000`.
|
12
|
+
- `LFSS_TOKEN`: The fallback token to authenticate. Should be `sha256(<username><password>)`.
|
docs/Known_issues.md
CHANGED
@@ -1 +1,3 @@
|
|
1
|
-
[Safari 中文输入法回车捕获](https://github.com/anse-app/anse/issues/127)
|
1
|
+
[Safari 中文输入法回车捕获](https://github.com/anse-app/anse/issues/127)
|
2
|
+
|
3
|
+
[Word 临时文件](https://answers.microsoft.com/en-us/msoffice/forum/all/mac-os-word-temp-sb-folders-created-on-smb-share/40fda56c-c77c-4365-8fa3-eb87ac814207?page=1)
|
docs/Webdav.md
CHANGED
@@ -15,8 +15,8 @@ Please note:
|
|
15
15
|
2. LFSS not allow creating files in the root directory, however some client such as [Finder](https://sabre.io/dav/clients/finder/) will try to create files in the root directory. Thus, it is safer to mount the user directory only, e.g. `http://localhost:8000/<username>/`.
|
16
16
|
3. LFSS not allow directory creation, instead it creates directoy implicitly when a file is uploaded to a non-exist directory.
|
17
17
|
i.e. `PUT http://localhost:8000/<username>/dir/file.txt` will create the `dir` directory if it does not exist.
|
18
|
-
However, the WebDAV `MKCOL` method requires the directory to be created explicitly, so WebDAV `MKCOL` method instead create a decoy file on the path (`.
|
18
|
+
However, the WebDAV `MKCOL` method requires the directory to be created explicitly, so WebDAV `MKCOL` method instead create a decoy file on the path (`.lfss_keep`), and hide the file from the file listing by `PROPFIND` method.
|
19
19
|
This leads to:
|
20
|
-
1) You may see a `.
|
21
|
-
2) The directory may be deleted if there is no file in it and the `.
|
20
|
+
1) You may see a `.lfss_keep` file in the directory with native file listing (e.g. `/_api/list-files`), but it is hidden in WebDAV clients.
|
21
|
+
2) The directory may be deleted if there is no file in it and the `.lfss_keep` file is not created by WebDAV client.
|
22
22
|
|
frontend/api.js
CHANGED
@@ -384,6 +384,27 @@ export default class Connector {
|
|
384
384
|
}
|
385
385
|
}
|
386
386
|
|
387
|
+
/**
|
388
|
+
* @param {string} srcPath - file path(url)
|
389
|
+
* @param {string} dstPath - new file path(url)
|
390
|
+
*/
|
391
|
+
async copy(srcPath, dstPath){
|
392
|
+
if (srcPath.startsWith('/')){ srcPath = srcPath.slice(1); }
|
393
|
+
if (dstPath.startsWith('/')){ dstPath = dstPath.slice(1); }
|
394
|
+
const dst = new URL(this.config.endpoint + '/_api/copy');
|
395
|
+
dst.searchParams.append('src', srcPath);
|
396
|
+
dst.searchParams.append('dst', dstPath);
|
397
|
+
const res = await fetch(dst.toString(), {
|
398
|
+
method: 'POST',
|
399
|
+
headers: {
|
400
|
+
'Authorization': 'Bearer ' + this.config.token,
|
401
|
+
'Content-Type': 'application/www-form-urlencoded'
|
402
|
+
},
|
403
|
+
});
|
404
|
+
if (!(res.status == 200 || res.status == 201)){
|
405
|
+
throw new Error(`Failed to copy file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
406
|
+
}
|
407
|
+
}
|
387
408
|
}
|
388
409
|
|
389
410
|
/**
|
frontend/scripts.js
CHANGED
@@ -347,7 +347,7 @@ async function refreshFileList(){
|
|
347
347
|
}
|
348
348
|
);
|
349
349
|
}, {
|
350
|
-
text: 'Enter the destination path: ',
|
350
|
+
text: 'Enter the destination path (Move): ',
|
351
351
|
placeholder: 'Destination path',
|
352
352
|
value: decodePathURI(dirurl),
|
353
353
|
select: "last-pathname"
|
@@ -355,6 +355,30 @@ async function refreshFileList(){
|
|
355
355
|
});
|
356
356
|
actContainer.appendChild(moveButton);
|
357
357
|
|
358
|
+
const copyButton = document.createElement('a');
|
359
|
+
copyButton.textContent = 'Copy';
|
360
|
+
copyButton.style.cursor = 'pointer';
|
361
|
+
copyButton.addEventListener('click', () => {
|
362
|
+
showFloatingWindowLineInput((dstPath) => {
|
363
|
+
dstPath = encodePathURI(dstPath);
|
364
|
+
console.log("Copying", dirurl, "to", dstPath);
|
365
|
+
conn.copy(dirurl, dstPath)
|
366
|
+
.then(() => {
|
367
|
+
refreshFileList();
|
368
|
+
},
|
369
|
+
(err) => {
|
370
|
+
showPopup('Failed to copy path: ' + err, {level: 'error'});
|
371
|
+
}
|
372
|
+
);
|
373
|
+
}, {
|
374
|
+
text: 'Enter the destination path (Copy): ',
|
375
|
+
placeholder: 'Destination path',
|
376
|
+
value: decodePathURI(dirurl),
|
377
|
+
select: "last-pathname"
|
378
|
+
});
|
379
|
+
});
|
380
|
+
actContainer.appendChild(copyButton);
|
381
|
+
|
358
382
|
const downloadButton = document.createElement('a');
|
359
383
|
downloadButton.textContent = 'Download';
|
360
384
|
downloadButton.href = conn.config.endpoint + '/_api/bundle?' +
|
@@ -478,7 +502,7 @@ async function refreshFileList(){
|
|
478
502
|
}
|
479
503
|
);
|
480
504
|
}, {
|
481
|
-
text: 'Enter the destination path: ',
|
505
|
+
text: 'Enter the destination path (Move): ',
|
482
506
|
placeholder: 'Destination path',
|
483
507
|
value: decodePathURI(file.url),
|
484
508
|
select: "last-filename"
|
@@ -486,6 +510,29 @@ async function refreshFileList(){
|
|
486
510
|
});
|
487
511
|
actContainer.appendChild(moveButton);
|
488
512
|
|
513
|
+
const copyButton = document.createElement('a');
|
514
|
+
copyButton.textContent = 'Copy';
|
515
|
+
copyButton.style.cursor = 'pointer';
|
516
|
+
copyButton.addEventListener('click', () => {
|
517
|
+
showFloatingWindowLineInput((dstPath) => {
|
518
|
+
dstPath = encodePathURI(dstPath);
|
519
|
+
conn.copy(file.url, dstPath)
|
520
|
+
.then(() => {
|
521
|
+
refreshFileList();
|
522
|
+
},
|
523
|
+
(err) => {
|
524
|
+
showPopup('Failed to copy file: ' + err, {level: 'error'});
|
525
|
+
}
|
526
|
+
);
|
527
|
+
}, {
|
528
|
+
text: 'Enter the destination path (Copy): ',
|
529
|
+
placeholder: 'Destination path',
|
530
|
+
value: decodePathURI(file.url),
|
531
|
+
select: "last-filename"
|
532
|
+
});
|
533
|
+
});
|
534
|
+
actContainer.appendChild(copyButton);
|
535
|
+
|
489
536
|
const downloadBtn = document.createElement('a');
|
490
537
|
downloadBtn.textContent = 'Download';
|
491
538
|
downloadBtn.href = conn.config.endpoint + '/' + file.url + '?download=true&token=' + conn.config.token;
|
lfss/api/connector.py
CHANGED
@@ -270,6 +270,12 @@ class Connector:
|
|
270
270
|
self._fetch_factory('POST', '_api/meta', {'path': path, 'new_path': new_path})(
|
271
271
|
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
272
272
|
)
|
273
|
+
|
274
|
+
def copy(self, src: str, dst: str):
|
275
|
+
"""Copy file from src to dst."""
|
276
|
+
self._fetch_factory('POST', '_api/copy', {'src': src, 'dst': dst})(
|
277
|
+
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
278
|
+
)
|
273
279
|
|
274
280
|
def whoami(self) -> UserRecord:
|
275
281
|
"""Gets information about the current user."""
|
lfss/eng/connection_pool.py
CHANGED
@@ -29,7 +29,7 @@ async def get_connection(read_only: bool = False) -> aiosqlite.Connection:
|
|
29
29
|
|
30
30
|
conn = await aiosqlite.connect(
|
31
31
|
get_db_uri(DATA_HOME / 'index.db', read_only=read_only),
|
32
|
-
timeout =
|
32
|
+
timeout = 10, uri = True
|
33
33
|
)
|
34
34
|
async with conn.cursor() as c:
|
35
35
|
await c.execute(
|
lfss/eng/database.py
CHANGED
@@ -161,7 +161,7 @@ class UserConn(DBObjectBase):
|
|
161
161
|
async def list_peer_users(self, src_user: int | str, level: AccessLevel) -> list[UserRecord]:
|
162
162
|
"""
|
163
163
|
List all users that src_user can do [AliasLevel] to, with level >= level,
|
164
|
-
Note: the returned list does not include src_user and admin users
|
164
|
+
Note: the returned list does not include src_user and is not apporiate for admin (who has all permissions for all users)
|
165
165
|
"""
|
166
166
|
assert int(level) > AccessLevel.NONE, f"Invalid level, {level}"
|
167
167
|
match src_user:
|
@@ -427,8 +427,7 @@ class FileConn(DBObjectBase):
|
|
427
427
|
await self._user_size_inc(user_id, old.file_size)
|
428
428
|
self.logger.info(f"Copied file {old_url} to {new_url}")
|
429
429
|
|
430
|
-
|
431
|
-
async def copy_path(self, old_url: str, new_url: str, conflict_handler: Literal['skip', 'overwrite'] = 'overwrite', user_id: Optional[int] = None):
|
430
|
+
async def copy_path(self, old_url: str, new_url: str, user_id: Optional[int] = None):
|
432
431
|
assert old_url.endswith('/'), "Old path must end with /"
|
433
432
|
assert new_url.endswith('/'), "New path must end with /"
|
434
433
|
if user_id is None:
|
@@ -440,11 +439,8 @@ class FileConn(DBObjectBase):
|
|
440
439
|
for r in res:
|
441
440
|
old_record = FileRecord(*r)
|
442
441
|
new_r = new_url + old_record.url[len(old_url):]
|
443
|
-
if
|
444
|
-
|
445
|
-
elif conflict_handler == 'skip':
|
446
|
-
if (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))) is not None:
|
447
|
-
continue
|
442
|
+
if await (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))).fetchone() is not None:
|
443
|
+
raise FileExistsError(f"File {new_r} already exists")
|
448
444
|
new_fid = str(uuid.uuid4())
|
449
445
|
user_id = old_record.owner_id if user_id is None else user_id
|
450
446
|
await self.cur.execute(
|
@@ -456,6 +452,7 @@ class FileConn(DBObjectBase):
|
|
456
452
|
else:
|
457
453
|
await copy_file(LARGE_BLOB_DIR / old_record.file_id, LARGE_BLOB_DIR / new_fid)
|
458
454
|
await self._user_size_inc(user_id, old_record.file_size)
|
455
|
+
self.logger.info(f"Copied path {old_url} to {new_url}")
|
459
456
|
|
460
457
|
async def move_file(self, old_url: str, new_url: str):
|
461
458
|
old = await self.get_file_record(old_url)
|
@@ -467,7 +464,7 @@ class FileConn(DBObjectBase):
|
|
467
464
|
await self.cur.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url))
|
468
465
|
self.logger.info(f"Moved file {old_url} to {new_url}")
|
469
466
|
|
470
|
-
async def move_path(self, old_url: str, new_url: str,
|
467
|
+
async def move_path(self, old_url: str, new_url: str, user_id: Optional[int] = None):
|
471
468
|
assert old_url.endswith('/'), "Old path must end with /"
|
472
469
|
assert new_url.endswith('/'), "New path must end with /"
|
473
470
|
if user_id is None:
|
@@ -478,11 +475,9 @@ class FileConn(DBObjectBase):
|
|
478
475
|
res = await cursor.fetchall()
|
479
476
|
for r in res:
|
480
477
|
new_r = new_url + r[0][len(old_url):]
|
481
|
-
if
|
482
|
-
|
483
|
-
|
484
|
-
if (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))) is not None:
|
485
|
-
continue
|
478
|
+
if await (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))).fetchone():
|
479
|
+
self.logger.error(f"File {new_r} already exists on move path: {old_url} -> {new_url}")
|
480
|
+
raise FileDuplicateError(f"File {new_r} already exists")
|
486
481
|
await self.cur.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_r, r[0]))
|
487
482
|
|
488
483
|
async def log_access(self, url: str):
|
@@ -790,6 +785,8 @@ class Database:
|
|
790
785
|
if op_user is not None:
|
791
786
|
if await check_path_permission(old_url, op_user, cursor=cur) < AccessLevel.WRITE:
|
792
787
|
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
|
788
|
+
if await check_path_permission(new_url, op_user, cursor=cur) < AccessLevel.WRITE:
|
789
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file to {new_url}")
|
793
790
|
await fconn.move_file(old_url, new_url)
|
794
791
|
|
795
792
|
new_mime, _ = mimetypes.guess_type(new_url)
|
@@ -834,7 +831,7 @@ class Database:
|
|
834
831
|
|
835
832
|
async with transaction() as cur:
|
836
833
|
fconn = FileConn(cur)
|
837
|
-
await fconn.move_path(old_url, new_url,
|
834
|
+
await fconn.move_path(old_url, new_url, op_user.id)
|
838
835
|
|
839
836
|
# not tested
|
840
837
|
async def copy_path(self, old_url: str, new_url: str, op_user: UserRecord):
|
@@ -858,7 +855,7 @@ class Database:
|
|
858
855
|
|
859
856
|
async with transaction() as cur:
|
860
857
|
fconn = FileConn(cur)
|
861
|
-
await fconn.copy_path(old_url, new_url,
|
858
|
+
await fconn.copy_path(old_url, new_url, op_user.id)
|
862
859
|
|
863
860
|
async def __batch_delete_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
|
864
861
|
# https://github.com/langchain-ai/langchain/issues/10321
|
lfss/eng/error.py
CHANGED
@@ -6,13 +6,17 @@ class FileLockedError(LFSSExceptionBase):...
|
|
6
6
|
|
7
7
|
class InvalidOptionsError(LFSSExceptionBase, ValueError):...
|
8
8
|
|
9
|
+
class InvalidDataError(LFSSExceptionBase, ValueError):...
|
10
|
+
|
11
|
+
class InvalidPathError(LFSSExceptionBase, ValueError):...
|
12
|
+
|
9
13
|
class DatabaseLockedError(LFSSExceptionBase, sqlite3.DatabaseError):...
|
10
14
|
|
11
15
|
class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
|
12
16
|
|
13
|
-
class
|
17
|
+
class FileDuplicateError(LFSSExceptionBase, FileExistsError):...
|
14
18
|
|
15
|
-
class
|
19
|
+
class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
|
16
20
|
|
17
21
|
class StorageExceededError(LFSSExceptionBase):...
|
18
22
|
|
lfss/eng/thumb.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
from lfss.eng.config import THUMB_DB, THUMB_SIZE
|
2
2
|
from lfss.eng.database import FileConn
|
3
|
+
from lfss.eng.error import *
|
3
4
|
from lfss.eng.connection_pool import unique_cursor
|
4
5
|
from typing import Optional
|
5
6
|
from PIL import Image
|
@@ -32,7 +33,10 @@ async def _get_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str) -> Option
|
|
32
33
|
return blob
|
33
34
|
|
34
35
|
async def _save_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str, raw_bytes: bytes) -> bytes:
|
35
|
-
|
36
|
+
try:
|
37
|
+
raw_img = Image.open(BytesIO(raw_bytes))
|
38
|
+
except Exception:
|
39
|
+
raise InvalidDataError('Invalid image data for thumbnail: ' + path)
|
36
40
|
raw_img.thumbnail(THUMB_SIZE)
|
37
41
|
img = raw_img.convert('RGB')
|
38
42
|
bio = BytesIO()
|
lfss/eng/utils.py
CHANGED
@@ -36,17 +36,41 @@ def ensure_uri_compnents(path: str):
|
|
36
36
|
""" Ensure the path components are safe to use """
|
37
37
|
return encode_uri_compnents(decode_uri_compnents(path))
|
38
38
|
|
39
|
-
|
40
|
-
|
39
|
+
class TaskManager:
|
40
|
+
def __init__(self):
|
41
|
+
self._tasks: OrderedDict[str, asyncio.Task] = OrderedDict()
|
42
|
+
|
43
|
+
def push(self, task: asyncio.Task) -> str:
|
44
|
+
tid = uuid4().hex
|
45
|
+
if tid in self._tasks:
|
46
|
+
raise ValueError("Task ID collision")
|
47
|
+
self._tasks[tid] = task
|
48
|
+
return tid
|
49
|
+
|
50
|
+
def cancel(self, task_id: str):
|
51
|
+
task = self._tasks.pop(task_id, None)
|
52
|
+
if task is not None:
|
53
|
+
task.cancel()
|
54
|
+
|
55
|
+
def truncate(self):
|
56
|
+
new_tasks = OrderedDict()
|
57
|
+
for tid, task in self._tasks.items():
|
58
|
+
if not task.done():
|
59
|
+
new_tasks[tid] = task
|
60
|
+
self._tasks = new_tasks
|
61
|
+
|
62
|
+
async def wait_all(self):
|
63
|
+
async def stop_task(task: asyncio.Task):
|
64
|
+
if not task.done():
|
65
|
+
await task
|
66
|
+
await asyncio.gather(*map(stop_task, self._tasks.values()))
|
67
|
+
self._tasks.clear()
|
68
|
+
|
69
|
+
def __len__(self): return len(self._tasks)
|
70
|
+
|
71
|
+
g_debounce_tasks: TaskManager = TaskManager()
|
41
72
|
async def wait_for_debounce_tasks():
|
42
|
-
|
43
|
-
task.cancel()
|
44
|
-
try:
|
45
|
-
await task
|
46
|
-
except asyncio.CancelledError:
|
47
|
-
pass
|
48
|
-
await asyncio.gather(*map(stop_task, g_debounce_tasks.values()))
|
49
|
-
g_debounce_tasks.clear()
|
73
|
+
await g_debounce_tasks.wait_all()
|
50
74
|
|
51
75
|
def debounce_async(delay: float = 0.1, max_wait: float = 1.):
|
52
76
|
"""
|
@@ -54,7 +78,8 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
|
|
54
78
|
ensuring execution at least once every `max_wait` seconds.
|
55
79
|
"""
|
56
80
|
def debounce_wrap(func):
|
57
|
-
task_record: tuple[str, asyncio.Task] | None = None
|
81
|
+
# task_record: tuple[str, asyncio.Task] | None = None
|
82
|
+
prev_task_id = None
|
58
83
|
fn_execution_lock = Lock()
|
59
84
|
last_execution_time = 0
|
60
85
|
|
@@ -67,12 +92,11 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
|
|
67
92
|
|
68
93
|
@functools.wraps(func)
|
69
94
|
async def wrapper(*args, **kwargs):
|
70
|
-
nonlocal
|
95
|
+
nonlocal prev_task_id, last_execution_time
|
71
96
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
g_debounce_tasks.pop(task_record[0], None)
|
97
|
+
if prev_task_id is not None:
|
98
|
+
g_debounce_tasks.cancel(prev_task_id)
|
99
|
+
prev_task_id = None
|
76
100
|
|
77
101
|
async with fn_execution_lock:
|
78
102
|
if time.monotonic() - last_execution_time > max_wait:
|
@@ -81,14 +105,12 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
|
|
81
105
|
return
|
82
106
|
|
83
107
|
task = asyncio.create_task(delayed_func(*args, **kwargs))
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
# so we need to clear it periodically
|
91
|
-
await wait_for_debounce_tasks()
|
108
|
+
prev_task_id = g_debounce_tasks.push(task)
|
109
|
+
if len(g_debounce_tasks) > 1024:
|
110
|
+
# finished tasks are not removed from the dict
|
111
|
+
# so we need to clear it periodically
|
112
|
+
g_debounce_tasks.truncate()
|
113
|
+
|
92
114
|
return wrapper
|
93
115
|
return debounce_wrap
|
94
116
|
|
lfss/svc/app.py
CHANGED
@@ -1,9 +1,9 @@
|
|
1
|
+
from .app_base import ENABLE_WEBDAV
|
1
2
|
from .app_native import *
|
2
|
-
import os
|
3
3
|
|
4
4
|
# order matters
|
5
5
|
app.include_router(router_api)
|
6
|
-
if
|
6
|
+
if ENABLE_WEBDAV:
|
7
7
|
from .app_dav import *
|
8
8
|
app.include_router(router_dav)
|
9
9
|
app.include_router(router_fs)
|
lfss/svc/app_base.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
import asyncio, time
|
1
|
+
import asyncio, time, os
|
2
2
|
from contextlib import asynccontextmanager
|
3
3
|
from typing import Optional
|
4
4
|
from functools import wraps
|
@@ -17,6 +17,7 @@ from ..eng.error import *
|
|
17
17
|
from ..eng.config import DEBUG_MODE
|
18
18
|
from .request_log import RequestDB
|
19
19
|
|
20
|
+
ENABLE_WEBDAV = os.environ.get("LFSS_WEBDAV", "0") == "1"
|
20
21
|
logger = get_logger("server", term_level="DEBUG")
|
21
22
|
logger_failed_request = get_logger("failed_requests", term_level="INFO")
|
22
23
|
db = Database()
|
@@ -46,12 +47,14 @@ def handle_exception(fn):
|
|
46
47
|
if isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
|
47
48
|
if isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
|
48
49
|
if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
|
50
|
+
if isinstance(e, InvalidOptionsError): raise HTTPException(status_code=400, detail=str(e))
|
51
|
+
if isinstance(e, InvalidDataError): raise HTTPException(status_code=400, detail=str(e))
|
49
52
|
if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
|
53
|
+
if isinstance(e, FileDuplicateError): raise HTTPException(status_code=409, detail=str(e))
|
50
54
|
if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
|
51
55
|
if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
|
52
56
|
if isinstance(e, DatabaseLockedError): raise HTTPException(status_code=503, detail=str(e))
|
53
57
|
if isinstance(e, FileLockedError): raise HTTPException(status_code=423, detail=str(e))
|
54
|
-
if isinstance(e, InvalidOptionsError): raise HTTPException(status_code=400, detail=str(e))
|
55
58
|
logger.error(f"Uncaptured error in {fn.__name__}: {e}")
|
56
59
|
raise
|
57
60
|
return wrapper
|
@@ -119,13 +122,13 @@ async def get_current_user(
|
|
119
122
|
uconn = UserConn(conn)
|
120
123
|
if h_token:
|
121
124
|
user = await uconn.get_user_by_credential(h_token.credentials)
|
122
|
-
if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic"})
|
123
|
-
elif b_token:
|
125
|
+
if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic" if ENABLE_WEBDAV else "Bearer"})
|
126
|
+
elif ENABLE_WEBDAV and b_token:
|
124
127
|
user = await uconn.get_user_by_credential(hash_credential(b_token.username, b_token.password))
|
125
|
-
if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic"})
|
128
|
+
if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic" if ENABLE_WEBDAV else "Bearer"})
|
126
129
|
elif q_token:
|
127
130
|
user = await uconn.get_user_by_credential(q_token)
|
128
|
-
if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic"})
|
131
|
+
if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic" if ENABLE_WEBDAV else "Bearer"})
|
129
132
|
else:
|
130
133
|
return DECOY_USER
|
131
134
|
|
@@ -136,10 +139,9 @@ async def get_current_user(
|
|
136
139
|
|
137
140
|
async def registered_user(user: UserRecord = Depends(get_current_user)):
|
138
141
|
if user.id == 0:
|
139
|
-
raise HTTPException(status_code=401, detail="Permission denied", headers={"WWW-Authenticate": "Basic"})
|
142
|
+
raise HTTPException(status_code=401, detail="Permission denied", headers={"WWW-Authenticate": "Basic" if ENABLE_WEBDAV else "Bearer"})
|
140
143
|
return user
|
141
144
|
|
142
|
-
|
143
145
|
router_api = APIRouter(prefix="/_api")
|
144
146
|
router_dav = APIRouter(prefix="")
|
145
147
|
router_fs = APIRouter(prefix="")
|