lfss 0.11.4__tar.gz → 0.11.6__tar.gz
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-0.11.4 → lfss-0.11.6}/PKG-INFO +2 -2
- lfss-0.11.6/docs/Client.md +40 -0
- {lfss-0.11.4 → lfss-0.11.6}/docs/changelog.md +21 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/api.js +1 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/scripts.js +2 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/api/__init__.py +3 -3
- {lfss-0.11.4 → lfss-0.11.6}/lfss/api/connector.py +10 -4
- {lfss-0.11.4 → lfss-0.11.6}/lfss/cli/cli.py +42 -8
- {lfss-0.11.4 → lfss-0.11.6}/lfss/cli/user.py +6 -3
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/database.py +19 -13
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/utils.py +4 -4
- {lfss-0.11.4 → lfss-0.11.6}/lfss/svc/app_dav.py +6 -6
- {lfss-0.11.4 → lfss-0.11.6}/lfss/svc/app_native.py +22 -12
- {lfss-0.11.4 → lfss-0.11.6}/lfss/svc/common_impl.py +7 -7
- {lfss-0.11.4 → lfss-0.11.6}/pyproject.toml +4 -3
- {lfss-0.11.4 → lfss-0.11.6}/Readme.md +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/docs/Enviroment_variables.md +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/docs/Known_issues.md +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/docs/Permission.md +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/docs/Webdav.md +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/index.html +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/info.css +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/info.js +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/login.css +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/login.js +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/popup.css +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/popup.js +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/state.js +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/styles.css +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/thumb.css +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/thumb.js +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/frontend/utils.js +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/cli/__init__.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/cli/balance.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/cli/log.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/cli/panel.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/cli/serve.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/cli/vacuum.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/__init__.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/bounded_pool.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/config.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/connection_pool.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/datatype.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/error.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/log.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/eng/thumb.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/sql/init.sql +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/sql/pragma.sql +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/svc/app.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/svc/app_base.py +0 -0
- {lfss-0.11.4 → lfss-0.11.6}/lfss/svc/request_log.py +0 -0
@@ -0,0 +1,40 @@
|
|
1
|
+
|
2
|
+
# Client-side CLI tools
|
3
|
+
|
4
|
+
To install python CLI tools without dependencies (to avoid conflicts with your existing packages):
|
5
|
+
```sh
|
6
|
+
pip install requests
|
7
|
+
pip install lfss --no-deps
|
8
|
+
```
|
9
|
+
|
10
|
+
Then set the `LFSS_ENDPOINT`, `LFSS_TOKEN` environment variables,
|
11
|
+
then you can use the following commands:
|
12
|
+
```sh
|
13
|
+
# Check current user information
|
14
|
+
lfss whoami
|
15
|
+
|
16
|
+
# Query a path
|
17
|
+
lfss query remote/file[/or_dir/]
|
18
|
+
|
19
|
+
# List directories of a specified path
|
20
|
+
lfss list-dirs remote/dir/
|
21
|
+
|
22
|
+
# List files of a specified path,
|
23
|
+
# with pagination and sorting
|
24
|
+
lfss list-files --offset 0 --limit 100 --order access_time remote/dir/
|
25
|
+
|
26
|
+
# Upload a file
|
27
|
+
lfss upload local/file.txt remote/file.txt
|
28
|
+
|
29
|
+
# Upload a directory, note the ending slashes
|
30
|
+
lfss upload local/dir/ remote/dir/
|
31
|
+
|
32
|
+
# Download a file
|
33
|
+
lfss download remote/file.txt local/file.txt
|
34
|
+
|
35
|
+
# Download a directory, with verbose output and 8 concurrent jobs
|
36
|
+
# Overwrite existing files
|
37
|
+
lfss download -v -j 8 --conflict overwrite remote/dir/ local/dir/
|
38
|
+
```
|
39
|
+
|
40
|
+
More commands can be found using `lfss-cli --help`.
|
@@ -1,5 +1,26 @@
|
|
1
1
|
## 0.11
|
2
2
|
|
3
|
+
### 0.11.6
|
4
|
+
- Hint copy and move success for frontend.
|
5
|
+
- Add query user info and list peers api.
|
6
|
+
- Add user with random password if not specified.
|
7
|
+
|
8
|
+
### 0.11.5
|
9
|
+
- Script entry default to client CLI.
|
10
|
+
- Fix single file download name deduce with decoding.
|
11
|
+
- Fix code misspell (minor).
|
12
|
+
|
13
|
+
### 0.11.4
|
14
|
+
- Fix SQL query for LIKE clause to escape special characters in path.
|
15
|
+
|
16
|
+
### 0.11.3
|
17
|
+
- Add method to get multiple files, maybe with content, at once.
|
18
|
+
- Allow copy directory files that the user is not the owner of.
|
19
|
+
- Environment variables to set origin and disable file logging.
|
20
|
+
- Fix error handling for some endpoints.
|
21
|
+
- Redirect CLI error output to stderr.
|
22
|
+
- Increase thumb image size to 64x64.
|
23
|
+
|
3
24
|
### 0.11.2
|
4
25
|
- Improve frontend directory upload feedback.
|
5
26
|
- Set default large file threashold to 1M.
|
@@ -249,6 +249,7 @@ export default class Connector {
|
|
249
249
|
/**
|
250
250
|
* @param {string} path - the path to the file directory, should ends with '/'
|
251
251
|
* @returns {Promise<PathListResponse>} - the promise of the request
|
252
|
+
* NOTE: will deprecated in future
|
252
253
|
*/
|
253
254
|
async listPath(path){
|
254
255
|
path = this._sanitizeDirPath(path);
|
@@ -347,6 +347,7 @@ async function refreshFileList(){
|
|
347
347
|
console.log("Moving", dirurl, "to", dstPath);
|
348
348
|
conn.move(dirurl, dstPath)
|
349
349
|
.then(() => {
|
350
|
+
showPopup('Successfully moved path.', {level: 'success', timeout: 3000});
|
350
351
|
refreshFileList();
|
351
352
|
},
|
352
353
|
(err) => {
|
@@ -371,6 +372,7 @@ async function refreshFileList(){
|
|
371
372
|
console.log("Copying", dirurl, "to", dstPath);
|
372
373
|
conn.copy(dirurl, dstPath)
|
373
374
|
.then(() => {
|
375
|
+
showPopup('Successfully copied path.', {level: 'success', timeout: 3000});
|
374
376
|
refreshFileList();
|
375
377
|
},
|
376
378
|
(err) => {
|
@@ -2,7 +2,7 @@ import os, time, pathlib
|
|
2
2
|
from threading import Lock
|
3
3
|
from .connector import Connector
|
4
4
|
from ..eng.datatype import FileRecord
|
5
|
-
from ..eng.utils import
|
5
|
+
from ..eng.utils import decode_uri_components
|
6
6
|
from ..eng.bounded_pool import BoundedThreadPoolExecutor
|
7
7
|
|
8
8
|
def upload_file(
|
@@ -105,7 +105,7 @@ def download_file(
|
|
105
105
|
assert not src_url.endswith('/'), "Source URL must not end with a slash."
|
106
106
|
while this_try <= n_retries:
|
107
107
|
if os.path.isdir(file_path):
|
108
|
-
fname = src_url.split('/')[-1]
|
108
|
+
fname = decode_uri_components(src_url.split('/')[-1])
|
109
109
|
file_path = os.path.join(file_path, fname)
|
110
110
|
|
111
111
|
if not overwrite and os.path.exists(file_path):
|
@@ -176,7 +176,7 @@ def download_directory(
|
|
176
176
|
with _counter_lock:
|
177
177
|
_counter += 1
|
178
178
|
this_count = _counter
|
179
|
-
dst_path = f"{directory}{os.path.relpath(
|
179
|
+
dst_path = f"{directory}{os.path.relpath(decode_uri_components(src_url), decode_uri_components(src_path))}"
|
180
180
|
if verbose:
|
181
181
|
print(f"[{this_count}/{file_count}] Downloading {src_url} to {dst_path}")
|
182
182
|
|
@@ -8,10 +8,10 @@ import urllib.parse
|
|
8
8
|
from tempfile import SpooledTemporaryFile
|
9
9
|
from lfss.eng.error import PathNotFoundError
|
10
10
|
from lfss.eng.datatype import (
|
11
|
-
FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents,
|
11
|
+
FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents, AccessLevel,
|
12
12
|
FileSortKey, DirSortKey
|
13
13
|
)
|
14
|
-
from lfss.eng.utils import
|
14
|
+
from lfss.eng.utils import ensure_uri_components
|
15
15
|
|
16
16
|
_default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
|
17
17
|
_default_token = os.environ.get('LFSS_TOKEN', '')
|
@@ -74,7 +74,7 @@ class Connector:
|
|
74
74
|
):
|
75
75
|
if path.startswith('/'):
|
76
76
|
path = path[1:]
|
77
|
-
path =
|
77
|
+
path = ensure_uri_components(path)
|
78
78
|
def f(**kwargs):
|
79
79
|
search_params_t = [
|
80
80
|
(k, str(v).lower() if isinstance(v, bool) else v)
|
@@ -316,4 +316,10 @@ class Connector:
|
|
316
316
|
def whoami(self) -> UserRecord:
|
317
317
|
"""Gets information about the current user."""
|
318
318
|
response = self._fetch_factory('GET', '_api/whoami')()
|
319
|
-
return UserRecord(**response.json())
|
319
|
+
return UserRecord(**response.json())
|
320
|
+
|
321
|
+
def list_peers(self, level: AccessLevel = AccessLevel.READ, incoming: bool = False) -> list[UserRecord]:
|
322
|
+
"""List all users that have at least the given access level to the current user."""
|
323
|
+
response = self._fetch_factory('GET', '_api/list-peers', {'level': int(level), 'incoming': incoming})()
|
324
|
+
users = [UserRecord(**u) for u in response.json()]
|
325
|
+
return users
|
@@ -1,8 +1,8 @@
|
|
1
1
|
from pathlib import Path
|
2
2
|
import argparse, typing, sys
|
3
3
|
from lfss.api import Connector, upload_directory, upload_file, download_file, download_directory
|
4
|
-
from lfss.eng.datatype import FileReadPermission, FileSortKey, DirSortKey
|
5
|
-
from lfss.eng.utils import
|
4
|
+
from lfss.eng.datatype import FileReadPermission, FileSortKey, DirSortKey, AccessLevel
|
5
|
+
from lfss.eng.utils import decode_uri_components, fmt_storage_size
|
6
6
|
from . import catch_request_error, line_sep
|
7
7
|
|
8
8
|
def parse_permission(s: str) -> FileReadPermission:
|
@@ -10,14 +10,27 @@ def parse_permission(s: str) -> FileReadPermission:
|
|
10
10
|
if p.name.lower() == s.lower():
|
11
11
|
return p
|
12
12
|
raise ValueError(f"Invalid permission {s}")
|
13
|
+
def parse_access_level(s: str) -> AccessLevel:
|
14
|
+
for p in AccessLevel:
|
15
|
+
if p.name.lower() == s.lower():
|
16
|
+
return p
|
17
|
+
raise ValueError(f"Invalid access level {s}")
|
13
18
|
|
14
19
|
def parse_arguments():
|
15
20
|
parser = argparse.ArgumentParser(description="Client-side command line interface, set LFSS_ENDPOINT and LFSS_TOKEN environment variables for authentication.")
|
16
21
|
|
17
22
|
sp = parser.add_subparsers(dest="command", required=True)
|
18
23
|
|
24
|
+
# whoami
|
25
|
+
sp_whoami = sp.add_parser("whoami", help="Show current user information")
|
26
|
+
|
27
|
+
# list peers
|
28
|
+
sp_peers = sp.add_parser("peers", help="Query users that you have access to or users that have access to you")
|
29
|
+
sp_peers.add_argument('-l', "--level", type=parse_access_level, default=AccessLevel.READ, help="Access level filter")
|
30
|
+
sp_peers.add_argument('-i', '--incoming', action='store_true', help="List users that have access to you (rather than you have access to them")
|
31
|
+
|
19
32
|
# upload
|
20
|
-
sp_upload = sp.add_parser("upload", help="Upload
|
33
|
+
sp_upload = sp.add_parser("upload", help="Upload file(s)")
|
21
34
|
sp_upload.add_argument("src", help="Source file or directory", type=str)
|
22
35
|
sp_upload.add_argument("dst", help="Destination url path", type=str)
|
23
36
|
sp_upload.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
@@ -28,7 +41,7 @@ def parse_arguments():
|
|
28
41
|
sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries")
|
29
42
|
|
30
43
|
# download
|
31
|
-
sp_download = sp.add_parser("download", help="Download
|
44
|
+
sp_download = sp.add_parser("download", help="Download file(s)")
|
32
45
|
sp_download.add_argument("src", help="Source url path", type=str)
|
33
46
|
sp_download.add_argument("dst", help="Destination file or directory", type=str)
|
34
47
|
sp_download.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
@@ -38,7 +51,7 @@ def parse_arguments():
|
|
38
51
|
sp_download.add_argument("--retries", type=int, default=0, help="Number of retries")
|
39
52
|
|
40
53
|
# query
|
41
|
-
sp_query = sp.add_parser("query", help="Query
|
54
|
+
sp_query = sp.add_parser("query", help="Query file or directories metadata from the server")
|
42
55
|
sp_query.add_argument("path", help="Path to query", nargs="*", type=str)
|
43
56
|
|
44
57
|
# list directories
|
@@ -65,7 +78,28 @@ def parse_arguments():
|
|
65
78
|
def main():
|
66
79
|
args = parse_arguments()
|
67
80
|
connector = Connector()
|
68
|
-
if args.command == "
|
81
|
+
if args.command == "whoami":
|
82
|
+
with catch_request_error():
|
83
|
+
user = connector.whoami()
|
84
|
+
print("Username:", user.username)
|
85
|
+
print("User ID:", user.id)
|
86
|
+
print("Is Admin:", bool(user.is_admin))
|
87
|
+
print("Max Storage:", fmt_storage_size(user.max_storage))
|
88
|
+
print("Default Permission:", user.permission.name)
|
89
|
+
print("Created At:", user.create_time)
|
90
|
+
print("Last Active:", user.last_active)
|
91
|
+
|
92
|
+
elif args.command == "peers":
|
93
|
+
with catch_request_error():
|
94
|
+
users = connector.list_peers(level=args.level, incoming=args.incoming)
|
95
|
+
if not args.incoming:
|
96
|
+
print(f"Peers that you have {args.level.name} access to:")
|
97
|
+
else:
|
98
|
+
print(f"Peers that have {args.level.name} access to you:")
|
99
|
+
for i, u in enumerate(line_sep(users)):
|
100
|
+
print(f"[{i+1}] {u.username} (id={u.id})")
|
101
|
+
|
102
|
+
elif args.command == "upload":
|
69
103
|
src_path = Path(args.src)
|
70
104
|
if src_path.is_dir():
|
71
105
|
failed_upload = upload_directory(
|
@@ -143,7 +177,7 @@ def main():
|
|
143
177
|
order_desc=args.reverse,
|
144
178
|
)
|
145
179
|
for i, f in enumerate(line_sep(res)):
|
146
|
-
f.url =
|
180
|
+
f.url = decode_uri_components(f.url)
|
147
181
|
print(f"[{i+1}] {f if args.long else f.url}")
|
148
182
|
|
149
183
|
if len(res) == args.limit:
|
@@ -160,7 +194,7 @@ def main():
|
|
160
194
|
order_desc=args.reverse,
|
161
195
|
)
|
162
196
|
for i, d in enumerate(line_sep(res)):
|
163
|
-
d.url =
|
197
|
+
d.url = decode_uri_components(d.url)
|
164
198
|
print(f"[{i+1}] {d if args.long else d.url}")
|
165
199
|
|
166
200
|
if len(res) == args.limit:
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import argparse, asyncio, os
|
1
|
+
import argparse, asyncio, os, secrets
|
2
2
|
from contextlib import asynccontextmanager
|
3
3
|
from .cli import parse_permission, FileReadPermission
|
4
4
|
from ..eng.utils import parse_storage_size, fmt_storage_size
|
@@ -18,10 +18,10 @@ async def _main():
|
|
18
18
|
sp = parser.add_subparsers(dest='subparser_name', required=True)
|
19
19
|
sp_add = sp.add_parser('add')
|
20
20
|
sp_add.add_argument('username', type=str)
|
21
|
-
sp_add.add_argument('password', type=str)
|
21
|
+
sp_add.add_argument('password', nargs='?', type=str, default=None)
|
22
22
|
sp_add.add_argument('--admin', action='store_true', help='Set user as admin')
|
23
23
|
sp_add.add_argument("--permission", type=parse_permission, default=FileReadPermission.UNSET, help="File permission, can be public, protected, private, or unset")
|
24
|
-
sp_add.add_argument('--max-storage', type=parse_storage_size, default="
|
24
|
+
sp_add.add_argument('--max-storage', type=parse_storage_size, default="10G", help="Maximum storage size, e.g. 1G, 100M, 10K, default is 10G")
|
25
25
|
|
26
26
|
sp_delete = sp.add_parser('delete')
|
27
27
|
sp_delete.add_argument('username', type=str)
|
@@ -58,6 +58,9 @@ async def _main():
|
|
58
58
|
|
59
59
|
if args.subparser_name == 'add':
|
60
60
|
async with get_uconn() as uconn:
|
61
|
+
if args.password is None:
|
62
|
+
passwd = secrets.token_urlsafe(16)
|
63
|
+
args.password = passwd
|
61
64
|
await uconn.create_user(args.username, args.password, args.admin, max_storage=args.max_storage, permission=args.permission)
|
62
65
|
user = await uconn.get_user(args.username)
|
63
66
|
assert user is not None
|
@@ -21,7 +21,7 @@ from .datatype import (
|
|
21
21
|
)
|
22
22
|
from .config import LARGE_BLOB_DIR, CHUNK_SIZE, LARGE_FILE_BYTES, MAX_MEM_FILE_BYTES
|
23
23
|
from .log import get_logger
|
24
|
-
from .utils import
|
24
|
+
from .utils import decode_uri_components, hash_credential, concurrent_wrap, debounce_async, static_vars
|
25
25
|
from .error import *
|
26
26
|
|
27
27
|
class DBObjectBase(ABC):
|
@@ -163,25 +163,31 @@ class UserConn(DBObjectBase):
|
|
163
163
|
return AccessLevel.NONE
|
164
164
|
return AccessLevel(res[0])
|
165
165
|
|
166
|
-
async def list_peer_users(self,
|
166
|
+
async def list_peer_users(self, user: int | str, level: AccessLevel, incoming = False) -> list[UserRecord]:
|
167
167
|
"""
|
168
|
-
|
169
|
-
|
168
|
+
if not incoming:
|
169
|
+
List all users that user can do [AliasLevel] to, with level >= level,
|
170
|
+
else:
|
171
|
+
List all users that can do [AliasLevel] to user, with level >= level
|
172
|
+
Note: the returned list does not include user and is not apporiate for admin (who has all permissions for all users)
|
170
173
|
"""
|
171
174
|
assert int(level) > AccessLevel.NONE, f"Invalid level, {level}"
|
172
|
-
|
175
|
+
aim_field = 'src_user_id' if incoming else 'dst_user_id'
|
176
|
+
query_field = 'dst_user_id' if incoming else 'src_user_id'
|
177
|
+
|
178
|
+
match user:
|
173
179
|
case int():
|
174
|
-
await self.cur.execute("""
|
180
|
+
await self.cur.execute(f"""
|
175
181
|
SELECT * FROM user WHERE id IN (
|
176
|
-
SELECT
|
182
|
+
SELECT {aim_field} FROM upeer WHERE {query_field} = ? AND access_level >= ?
|
177
183
|
)
|
178
|
-
""", (
|
184
|
+
""", (user, int(level)))
|
179
185
|
case str():
|
180
|
-
await self.cur.execute("""
|
186
|
+
await self.cur.execute(f"""
|
181
187
|
SELECT * FROM user WHERE id IN (
|
182
|
-
SELECT
|
188
|
+
SELECT {aim_field} FROM upeer WHERE {query_field} = (SELECT id FROM user WHERE username = ?) AND access_level >= ?
|
183
189
|
)
|
184
|
-
""", (
|
190
|
+
""", (user, int(level)))
|
185
191
|
case _:
|
186
192
|
raise ValueError("Invalid arguments")
|
187
193
|
res = await self.cur.fetchall()
|
@@ -1108,7 +1114,7 @@ class Database:
|
|
1108
1114
|
async def data_iter():
|
1109
1115
|
async for (r, blob) in self.iter_dir(top_url, None):
|
1110
1116
|
rel_path = r.url[len(top_url):]
|
1111
|
-
rel_path =
|
1117
|
+
rel_path = decode_uri_components(rel_path)
|
1112
1118
|
b_iter: AsyncIterable[bytes]
|
1113
1119
|
if isinstance(blob, bytes):
|
1114
1120
|
async def blob_iter(): yield blob
|
@@ -1138,7 +1144,7 @@ class Database:
|
|
1138
1144
|
with zipfile.ZipFile(buffer, 'w') as zf:
|
1139
1145
|
async for (r, blob) in self.iter_dir(top_url, None):
|
1140
1146
|
rel_path = r.url[len(top_url):]
|
1141
|
-
rel_path =
|
1147
|
+
rel_path = decode_uri_components(rel_path)
|
1142
1148
|
if r.external:
|
1143
1149
|
assert isinstance(blob, AsyncIterable)
|
1144
1150
|
zf.writestr(rel_path, b''.join([chunk async for chunk in blob]))
|
@@ -21,19 +21,19 @@ async def copy_file(source: str|pathlib.Path, destination: str|pathlib.Path):
|
|
21
21
|
def hash_credential(username: str, password: str):
|
22
22
|
return hashlib.sha256(f"{username}:{password}".encode()).hexdigest()
|
23
23
|
|
24
|
-
def
|
24
|
+
def encode_uri_components(path: str):
|
25
25
|
path_sp = path.split("/")
|
26
26
|
mapped = map(lambda x: urllib.parse.quote(x), path_sp)
|
27
27
|
return "/".join(mapped)
|
28
28
|
|
29
|
-
def
|
29
|
+
def decode_uri_components(path: str):
|
30
30
|
path_sp = path.split("/")
|
31
31
|
mapped = map(lambda x: urllib.parse.unquote(x), path_sp)
|
32
32
|
return "/".join(mapped)
|
33
33
|
|
34
|
-
def
|
34
|
+
def ensure_uri_components(path: str):
|
35
35
|
""" Ensure the path components are safe to use """
|
36
|
-
return
|
36
|
+
return encode_uri_components(decode_uri_components(path))
|
37
37
|
|
38
38
|
class TaskManager:
|
39
39
|
def __init__(self):
|
@@ -11,7 +11,7 @@ from ..eng.error import *
|
|
11
11
|
from ..eng.config import DATA_HOME, DEBUG_MODE
|
12
12
|
from ..eng.datatype import UserRecord, FileRecord, DirectoryRecord, AccessLevel
|
13
13
|
from ..eng.database import FileConn, UserConn, check_path_permission
|
14
|
-
from ..eng.utils import
|
14
|
+
from ..eng.utils import ensure_uri_components, decode_uri_components, format_last_modified, static_vars
|
15
15
|
from .app_base import *
|
16
16
|
from .common_impl import copy_impl
|
17
17
|
|
@@ -36,7 +36,7 @@ async def eval_path(path: str) -> tuple[ptype, str, Optional[FileRecord | Direct
|
|
36
36
|
and should end with / if it is a directory, otherwise it is a file
|
37
37
|
record is the FileRecord or DirectoryRecord object, it is None if the path does not exist
|
38
38
|
"""
|
39
|
-
path =
|
39
|
+
path = decode_uri_components(path)
|
40
40
|
if "://" in path:
|
41
41
|
if not path.startswith("http://") and not path.startswith("https://"):
|
42
42
|
raise HTTPException(status_code=400, detail="Bad Request, unsupported protocol")
|
@@ -47,7 +47,7 @@ async def eval_path(path: str) -> tuple[ptype, str, Optional[FileRecord | Direct
|
|
47
47
|
assert path.startswith(route_prefix), "Path should start with the route prefix, got: " + path
|
48
48
|
path = path[len(route_prefix):]
|
49
49
|
|
50
|
-
path =
|
50
|
+
path = ensure_uri_components(path)
|
51
51
|
if path.startswith("/"): path = path[1:]
|
52
52
|
|
53
53
|
# path now is url-safe and without leading slash
|
@@ -160,7 +160,7 @@ async def create_file_xml_element(frecord: FileRecord) -> ET.Element:
|
|
160
160
|
href.text = f"/{frecord.url}"
|
161
161
|
propstat = ET.SubElement(file_el, f"{{{DAV_NS}}}propstat")
|
162
162
|
prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
|
163
|
-
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text =
|
163
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = decode_uri_components(frecord.url.split("/")[-1])
|
164
164
|
ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype")
|
165
165
|
ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(frecord.file_size)
|
166
166
|
ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(frecord.create_time)
|
@@ -178,7 +178,7 @@ async def create_dir_xml_element(drecord: DirectoryRecord) -> ET.Element:
|
|
178
178
|
href.text = f"/{drecord.url}"
|
179
179
|
propstat = ET.SubElement(dir_el, f"{{{DAV_NS}}}propstat")
|
180
180
|
prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
|
181
|
-
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text =
|
181
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = decode_uri_components(drecord.url.split("/")[-2])
|
182
182
|
ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype").append(ET.Element(f"{{{DAV_NS}}}collection"))
|
183
183
|
if drecord.size >= 0:
|
184
184
|
ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(drecord.create_time)
|
@@ -211,7 +211,7 @@ async def dav_options(request: Request, path: str):
|
|
211
211
|
@handle_exception
|
212
212
|
async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(registered_user), body: Optional[ET.Element] = Depends(xml_request_body)):
|
213
213
|
if path.startswith("/"): path = path[1:]
|
214
|
-
path =
|
214
|
+
path = ensure_uri_components(path)
|
215
215
|
|
216
216
|
if body and DEBUG_MODE:
|
217
217
|
print("Propfind-body:", ET.tostring(body, encoding="utf-8", method="xml"))
|
@@ -5,10 +5,10 @@ from fastapi import Depends, Request, Response, UploadFile, Query
|
|
5
5
|
from fastapi.responses import StreamingResponse, JSONResponse
|
6
6
|
from fastapi.exceptions import HTTPException
|
7
7
|
|
8
|
-
from ..eng.utils import
|
8
|
+
from ..eng.utils import ensure_uri_components
|
9
9
|
from ..eng.config import MAX_MEM_FILE_BYTES
|
10
10
|
from ..eng.connection_pool import unique_cursor
|
11
|
-
from ..eng.database import check_file_read_permission, check_path_permission, FileConn, delayed_log_access
|
11
|
+
from ..eng.database import check_file_read_permission, check_path_permission, FileConn, delayed_log_access, UserConn
|
12
12
|
from ..eng.datatype import (
|
13
13
|
FileReadPermission, UserRecord, AccessLevel,
|
14
14
|
FileSortKey, DirSortKey
|
@@ -83,7 +83,7 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
|
|
83
83
|
@handle_exception
|
84
84
|
async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
|
85
85
|
logger.info(f"GET bundle({path}), user: {user.username}")
|
86
|
-
path =
|
86
|
+
path = ensure_uri_components(path)
|
87
87
|
if not path.endswith("/"):
|
88
88
|
raise HTTPException(status_code=400, detail="Path must end with /")
|
89
89
|
if path[0] == "/": # adapt to both /path and path
|
@@ -123,7 +123,7 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
|
|
123
123
|
@handle_exception
|
124
124
|
async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
|
125
125
|
logger.info(f"GET meta({path}), user: {user.username}")
|
126
|
-
path =
|
126
|
+
path = ensure_uri_components(path)
|
127
127
|
is_file = not path.endswith("/")
|
128
128
|
async with unique_cursor() as cur:
|
129
129
|
fconn = FileConn(cur)
|
@@ -147,7 +147,7 @@ async def update_file_meta(
|
|
147
147
|
new_path: Optional[str] = None,
|
148
148
|
user: UserRecord = Depends(registered_user)
|
149
149
|
):
|
150
|
-
path =
|
150
|
+
path = ensure_uri_components(path)
|
151
151
|
if path.startswith("/"):
|
152
152
|
path = path[1:]
|
153
153
|
|
@@ -162,7 +162,7 @@ async def update_file_meta(
|
|
162
162
|
)
|
163
163
|
|
164
164
|
if new_path is not None:
|
165
|
-
new_path =
|
165
|
+
new_path = ensure_uri_components(new_path)
|
166
166
|
logger.info(f"Update path of {path} to {new_path}")
|
167
167
|
await db.move_file(path, new_path, user)
|
168
168
|
|
@@ -170,7 +170,7 @@ async def update_file_meta(
|
|
170
170
|
else:
|
171
171
|
assert perm is None, "Permission is not supported for directory"
|
172
172
|
if new_path is not None:
|
173
|
-
new_path =
|
173
|
+
new_path = ensure_uri_components(new_path)
|
174
174
|
logger.info(f"Update path of {path} to {new_path}")
|
175
175
|
# will raise duplicate path error if same name path exists in the new path
|
176
176
|
await db.move_dir(path, new_path, user)
|
@@ -185,6 +185,16 @@ async def copy_file(
|
|
185
185
|
):
|
186
186
|
return await copy_impl(src_path = src, dst_path = dst, op_user = user)
|
187
187
|
|
188
|
+
@router_api.get("/list-peers")
|
189
|
+
@handle_exception
|
190
|
+
async def list_peers(user: UserRecord = Depends(registered_user), level: AccessLevel = AccessLevel.READ, incoming: bool = False):
|
191
|
+
async with unique_cursor() as conn:
|
192
|
+
uconn = UserConn(conn)
|
193
|
+
peer_users = await uconn.list_peer_users(user.id, level, incoming=incoming)
|
194
|
+
for u in peer_users:
|
195
|
+
u.credential = "__HIDDEN__"
|
196
|
+
return peer_users
|
197
|
+
|
188
198
|
async def validate_path_read_permission(path: str, user: UserRecord):
|
189
199
|
if not path.endswith("/"):
|
190
200
|
raise HTTPException(status_code=400, detail="Path must end with /")
|
@@ -194,7 +204,7 @@ async def validate_path_read_permission(path: str, user: UserRecord):
|
|
194
204
|
@handle_exception
|
195
205
|
async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
|
196
206
|
await validate_path_read_permission(path, user)
|
197
|
-
path =
|
207
|
+
path = ensure_uri_components(path)
|
198
208
|
async with unique_cursor() as conn:
|
199
209
|
fconn = FileConn(conn)
|
200
210
|
return { "count": await fconn.count_dir_files(url = path, flat = flat) }
|
@@ -206,7 +216,7 @@ async def list_files(
|
|
206
216
|
flat: bool = False, user: UserRecord = Depends(registered_user)
|
207
217
|
):
|
208
218
|
await validate_path_read_permission(path, user)
|
209
|
-
path =
|
219
|
+
path = ensure_uri_components(path)
|
210
220
|
async with unique_cursor() as conn:
|
211
221
|
fconn = FileConn(conn)
|
212
222
|
return await fconn.list_dir_files(
|
@@ -219,7 +229,7 @@ async def list_files(
|
|
219
229
|
@handle_exception
|
220
230
|
async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
|
221
231
|
await validate_path_read_permission(path, user)
|
222
|
-
path =
|
232
|
+
path = ensure_uri_components(path)
|
223
233
|
async with unique_cursor() as conn:
|
224
234
|
fconn = FileConn(conn)
|
225
235
|
return { "count": await fconn.count_path_dirs(url = path) }
|
@@ -231,7 +241,7 @@ async def list_dirs(
|
|
231
241
|
skim: bool = True, user: UserRecord = Depends(registered_user)
|
232
242
|
):
|
233
243
|
await validate_path_read_permission(path, user)
|
234
|
-
path =
|
244
|
+
path = ensure_uri_components(path)
|
235
245
|
async with unique_cursor() as conn:
|
236
246
|
fconn = FileConn(conn)
|
237
247
|
return await fconn.list_path_dirs(
|
@@ -263,7 +273,7 @@ async def get_multiple_files(
|
|
263
273
|
upath2path = OrderedDict[str, str]()
|
264
274
|
for p in path:
|
265
275
|
p_ = p if not p.startswith("/") else p[1:]
|
266
|
-
upath2path[
|
276
|
+
upath2path[ensure_uri_components(p_)] = p
|
267
277
|
upaths = list(upath2path.keys())
|
268
278
|
|
269
279
|
# get files
|
@@ -6,7 +6,7 @@ from ..eng.connection_pool import unique_cursor
|
|
6
6
|
from ..eng.datatype import UserRecord, FileRecord, PathContents, AccessLevel, FileReadPermission
|
7
7
|
from ..eng.database import FileConn, UserConn, delayed_log_access, check_file_read_permission, check_path_permission
|
8
8
|
from ..eng.thumb import get_thumb
|
9
|
-
from ..eng.utils import format_last_modified,
|
9
|
+
from ..eng.utils import format_last_modified, ensure_uri_components
|
10
10
|
from ..eng.config import CHUNK_SIZE, DEBUG_MODE
|
11
11
|
|
12
12
|
from .app_base import skip_request_log, db, logger
|
@@ -100,7 +100,7 @@ async def get_impl(
|
|
100
100
|
thumb: bool = False,
|
101
101
|
is_head = False,
|
102
102
|
):
|
103
|
-
path =
|
103
|
+
path = ensure_uri_components(path)
|
104
104
|
if path.startswith("/"): path = path[1:]
|
105
105
|
|
106
106
|
# handle directory query
|
@@ -194,7 +194,7 @@ async def put_file_impl(
|
|
194
194
|
conflict: Literal["overwrite", "skip", "abort"] = "overwrite",
|
195
195
|
permission: int = 0,
|
196
196
|
):
|
197
|
-
path =
|
197
|
+
path = ensure_uri_components(path)
|
198
198
|
assert not path.endswith("/"), "Path must not end with /"
|
199
199
|
|
200
200
|
access_level = await check_path_permission(path, user)
|
@@ -249,7 +249,7 @@ async def post_file_impl(
|
|
249
249
|
conflict: Literal["overwrite", "skip", "abort"] = "overwrite",
|
250
250
|
permission: int = 0,
|
251
251
|
):
|
252
|
-
path =
|
252
|
+
path = ensure_uri_components(path)
|
253
253
|
assert not path.endswith("/"), "Path must not end with /"
|
254
254
|
|
255
255
|
access_level = await check_path_permission(path, user)
|
@@ -288,7 +288,7 @@ async def post_file_impl(
|
|
288
288
|
}, content=json.dumps({"url": path}))
|
289
289
|
|
290
290
|
async def delete_impl(path: str, user: UserRecord):
|
291
|
-
path =
|
291
|
+
path = ensure_uri_components(path)
|
292
292
|
if await check_path_permission(path, user) < AccessLevel.WRITE:
|
293
293
|
raise HTTPException(status_code=403, detail="Permission denied")
|
294
294
|
|
@@ -307,8 +307,8 @@ async def delete_impl(path: str, user: UserRecord):
|
|
307
307
|
async def copy_impl(
|
308
308
|
op_user: UserRecord, src_path: str, dst_path: str,
|
309
309
|
):
|
310
|
-
src_path =
|
311
|
-
dst_path =
|
310
|
+
src_path = ensure_uri_components(src_path)
|
311
|
+
dst_path = ensure_uri_components(dst_path)
|
312
312
|
copy_type = "file" if not src_path[-1] == "/" else "directory"
|
313
313
|
if (src_path[-1] == "/") != (dst_path[-1] == "/"):
|
314
314
|
raise HTTPException(status_code=400, detail="Source and destination must be same type")
|
@@ -1,7 +1,7 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "lfss"
|
3
|
-
version = "0.11.
|
4
|
-
description = "
|
3
|
+
version = "0.11.6"
|
4
|
+
description = "Lite file storage service"
|
5
5
|
authors = ["Li, Mengxun <mengxunli@whu.edu.cn>"]
|
6
6
|
readme = "Readme.md"
|
7
7
|
homepage = "https://github.com/MenxLi/lfss"
|
@@ -30,10 +30,11 @@ webdavclient3 = "*"
|
|
30
30
|
lfss-serve = "lfss.cli.serve:main"
|
31
31
|
lfss-user = "lfss.cli.user:main"
|
32
32
|
lfss-panel = "lfss.cli.panel:main"
|
33
|
-
lfss-cli = "lfss.cli.cli:main"
|
34
33
|
lfss-vacuum = "lfss.cli.vacuum:main"
|
35
34
|
lfss-balance = "lfss.cli.balance:main"
|
36
35
|
lfss-log = "lfss.cli.log:main"
|
36
|
+
lfss-cli = "lfss.cli.cli:main"
|
37
|
+
lfss = "lfss.cli.cli:main"
|
37
38
|
|
38
39
|
[build-system]
|
39
40
|
requires = ["poetry-core>=1.0.0"]
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|