lfss 0.7.3__tar.gz → 0.7.5__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.7.3 → lfss-0.7.5}/PKG-INFO +1 -1
- {lfss-0.7.3 → lfss-0.7.5}/docs/Permission.md +7 -0
- {lfss-0.7.3 → lfss-0.7.5}/frontend/api.js +7 -2
- lfss-0.7.5/lfss/cli/cli.py +95 -0
- lfss-0.7.5/lfss/client/__init__.py +155 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/client/api.py +4 -2
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/config.py +1 -4
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/connection_pool.py +39 -28
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/database.py +23 -35
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/log.py +1 -4
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/server.py +28 -16
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/utils.py +7 -16
- {lfss-0.7.3 → lfss-0.7.5}/pyproject.toml +1 -1
- lfss-0.7.3/lfss/cli/cli.py +0 -52
- lfss-0.7.3/lfss/client/__init__.py +0 -60
- {lfss-0.7.3 → lfss-0.7.5}/Readme.md +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/docs/Known_issues.md +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/frontend/index.html +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/frontend/popup.css +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/frontend/popup.js +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/frontend/scripts.js +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/frontend/styles.css +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/frontend/utils.js +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/cli/balance.py +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/cli/panel.py +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/cli/serve.py +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/cli/user.py +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/sql/init.sql +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/sql/pragma.sql +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/__init__.py +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/datatype.py +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/error.py +0 -0
- {lfss-0.7.3 → lfss-0.7.5}/lfss/src/stat.py +0 -0
@@ -18,6 +18,13 @@ Non-admin users can access files based on:
|
|
18
18
|
- If the file is `unset`, then the file's permission is inherited from the owner's permission.
|
19
19
|
- If both the owner and the file have `unset` permission, then the file is `public`.
|
20
20
|
|
21
|
+
### Meta-data access
|
22
|
+
- Non-login users can't access any file-meta.
|
23
|
+
- All users can access the file-meta of files under their own path.
|
24
|
+
- For files under other users' path, the file-meta is determined in a way same as file access.
|
25
|
+
- Admins can access the path-meta of all users.
|
26
|
+
- All users can access the path-meta of their own path.
|
27
|
+
|
21
28
|
### Path-listing
|
22
29
|
- Non-login users cannot list any files.
|
23
30
|
- All users can list the files under their own path
|
@@ -133,12 +133,17 @@ export default class Connector {
|
|
133
133
|
|
134
134
|
/**
|
135
135
|
* @param {string} path - the path to the file directory, should ends with '/'
|
136
|
+
* @param {Object} options - the options for the request
|
136
137
|
* @returns {Promise<PathListResponse>} - the promise of the request
|
137
138
|
*/
|
138
|
-
async listPath(path
|
139
|
+
async listPath(path, {
|
140
|
+
flat = false
|
141
|
+
} = {}){
|
139
142
|
if (path.startsWith('/')){ path = path.slice(1); }
|
140
143
|
if (!path.endsWith('/')){ path += '/'; }
|
141
|
-
const
|
144
|
+
const dst = new URL(this.config.endpoint + '/' + path);
|
145
|
+
dst.searchParams.append('flat', flat);
|
146
|
+
const res = await fetch(dst.toString(), {
|
142
147
|
method: 'GET',
|
143
148
|
headers: {
|
144
149
|
'Authorization': 'Bearer ' + this.config.token
|
@@ -0,0 +1,95 @@
|
|
1
|
+
from lfss.client import Connector, upload_directory, upload_file, download_file, download_directory
|
2
|
+
from lfss.src.datatype import FileReadPermission
|
3
|
+
from pathlib import Path
|
4
|
+
import argparse
|
5
|
+
|
6
|
+
def parse_arguments():
|
7
|
+
parser = argparse.ArgumentParser(description="Command line interface, please set LFSS_ENDPOINT and LFSS_TOKEN environment variables.")
|
8
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
9
|
+
|
10
|
+
sp = parser.add_subparsers(dest="command", required=True)
|
11
|
+
|
12
|
+
# upload
|
13
|
+
sp_upload = sp.add_parser("upload", help="Upload files")
|
14
|
+
sp_upload.add_argument("src", help="Source file or directory", type=str)
|
15
|
+
sp_upload.add_argument("dst", help="Destination url path", type=str)
|
16
|
+
sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
|
17
|
+
sp_upload.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory upload")
|
18
|
+
sp_upload.add_argument("--conflict", choices=["overwrite", "abort", "skip", "skip-ahead"], default="abort", help="Conflict resolution")
|
19
|
+
sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
|
20
|
+
sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries")
|
21
|
+
|
22
|
+
# download
|
23
|
+
sp_download = sp.add_parser("download", help="Download files")
|
24
|
+
sp_download.add_argument("src", help="Source url path", type=str)
|
25
|
+
sp_download.add_argument("dst", help="Destination file or directory", type=str)
|
26
|
+
sp_download.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent downloads")
|
27
|
+
sp_download.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory download")
|
28
|
+
sp_download.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
|
29
|
+
sp_download.add_argument("--retries", type=int, default=0, help="Number of retries")
|
30
|
+
|
31
|
+
return parser.parse_args()
|
32
|
+
|
33
|
+
def main():
|
34
|
+
args = parse_arguments()
|
35
|
+
connector = Connector()
|
36
|
+
if args.command == "upload":
|
37
|
+
src_path = Path(args.src)
|
38
|
+
if src_path.is_dir():
|
39
|
+
failed_upload = upload_directory(
|
40
|
+
connector, args.src, args.dst,
|
41
|
+
verbose=args.verbose,
|
42
|
+
n_concurrent=args.jobs,
|
43
|
+
n_retries=args.retries,
|
44
|
+
interval=args.interval,
|
45
|
+
conflict=args.conflict,
|
46
|
+
permission=args.permission
|
47
|
+
)
|
48
|
+
if failed_upload:
|
49
|
+
print("Failed to upload:")
|
50
|
+
for path in failed_upload:
|
51
|
+
print(f" {path}")
|
52
|
+
else:
|
53
|
+
success = upload_file(
|
54
|
+
connector,
|
55
|
+
file_path = args.src,
|
56
|
+
dst_url = args.dst,
|
57
|
+
verbose=args.verbose,
|
58
|
+
n_retries=args.retries,
|
59
|
+
interval=args.interval,
|
60
|
+
conflict=args.conflict,
|
61
|
+
permission=args.permission
|
62
|
+
)
|
63
|
+
if not success:
|
64
|
+
print("Failed to upload.")
|
65
|
+
|
66
|
+
elif args.command == "download":
|
67
|
+
is_dir = args.src.endswith("/")
|
68
|
+
if is_dir:
|
69
|
+
failed_download = download_directory(
|
70
|
+
connector, args.src, args.dst,
|
71
|
+
verbose=args.verbose,
|
72
|
+
n_concurrent=args.jobs,
|
73
|
+
n_retries=args.retries,
|
74
|
+
interval=args.interval,
|
75
|
+
overwrite=args.overwrite
|
76
|
+
)
|
77
|
+
if failed_download:
|
78
|
+
print("Failed to download:")
|
79
|
+
for path in failed_download:
|
80
|
+
print(f" {path}")
|
81
|
+
else:
|
82
|
+
success = download_file(
|
83
|
+
connector,
|
84
|
+
src_url = args.src,
|
85
|
+
file_path = args.dst,
|
86
|
+
verbose=args.verbose,
|
87
|
+
n_retries=args.retries,
|
88
|
+
interval=args.interval,
|
89
|
+
overwrite=args.overwrite
|
90
|
+
)
|
91
|
+
if not success:
|
92
|
+
print("Failed to download.")
|
93
|
+
else:
|
94
|
+
raise NotImplementedError(f"Command {args.command} not implemented.")
|
95
|
+
|
@@ -0,0 +1,155 @@
|
|
1
|
+
import os, time, pathlib
|
2
|
+
from threading import Lock
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
4
|
+
from .api import Connector
|
5
|
+
|
6
|
+
def upload_file(
|
7
|
+
connector: Connector,
|
8
|
+
file_path: str,
|
9
|
+
dst_url: str,
|
10
|
+
n_retries: int = 0,
|
11
|
+
interval: float = 0,
|
12
|
+
verbose: bool = False,
|
13
|
+
**put_kwargs
|
14
|
+
):
|
15
|
+
this_try = 0
|
16
|
+
while this_try <= n_retries:
|
17
|
+
try:
|
18
|
+
with open(file_path, 'rb') as f:
|
19
|
+
blob = f.read()
|
20
|
+
connector.put(dst_url, blob, **put_kwargs)
|
21
|
+
break
|
22
|
+
except Exception as e:
|
23
|
+
if isinstance(e, KeyboardInterrupt):
|
24
|
+
raise e
|
25
|
+
if verbose:
|
26
|
+
print(f"Error uploading {file_path}: {e}, retrying...")
|
27
|
+
this_try += 1
|
28
|
+
finally:
|
29
|
+
time.sleep(interval)
|
30
|
+
|
31
|
+
if this_try > n_retries:
|
32
|
+
if verbose:
|
33
|
+
print(f"Failed to upload {file_path} after {n_retries} retries.")
|
34
|
+
return False
|
35
|
+
return True
|
36
|
+
|
37
|
+
def upload_directory(
|
38
|
+
connector: Connector,
|
39
|
+
directory: str,
|
40
|
+
path: str,
|
41
|
+
n_concurrent: int = 1,
|
42
|
+
n_retries: int = 0,
|
43
|
+
interval: float = 0,
|
44
|
+
verbose: bool = False,
|
45
|
+
**put_kwargs
|
46
|
+
) -> list[str]:
|
47
|
+
assert path.endswith('/'), "Path must end with a slash."
|
48
|
+
if path.startswith('/'):
|
49
|
+
path = path[1:]
|
50
|
+
directory = str(directory)
|
51
|
+
|
52
|
+
_counter = 0
|
53
|
+
_counter_lock = Lock()
|
54
|
+
|
55
|
+
faild_files = []
|
56
|
+
def put_file(file_path):
|
57
|
+
with _counter_lock:
|
58
|
+
nonlocal _counter
|
59
|
+
_counter += 1
|
60
|
+
this_count = _counter
|
61
|
+
dst_path = f"{path}{os.path.relpath(file_path, directory)}"
|
62
|
+
if verbose:
|
63
|
+
print(f"[{this_count}] Uploading {file_path} to {dst_path}")
|
64
|
+
|
65
|
+
if not upload_file(
|
66
|
+
connector, file_path, dst_path,
|
67
|
+
n_retries=n_retries, interval=interval, verbose=verbose, **put_kwargs
|
68
|
+
):
|
69
|
+
faild_files.append(file_path)
|
70
|
+
|
71
|
+
with ThreadPoolExecutor(n_concurrent) as executor:
|
72
|
+
for root, dirs, files in os.walk(directory):
|
73
|
+
for file in files:
|
74
|
+
executor.submit(put_file, os.path.join(root, file))
|
75
|
+
|
76
|
+
return faild_files
|
77
|
+
|
78
|
+
def download_file(
|
79
|
+
connector: Connector,
|
80
|
+
src_url: str,
|
81
|
+
file_path: str,
|
82
|
+
n_retries: int = 0,
|
83
|
+
interval: float = 0,
|
84
|
+
verbose: bool = False,
|
85
|
+
overwrite: bool = False
|
86
|
+
):
|
87
|
+
this_try = 0
|
88
|
+
while this_try <= n_retries:
|
89
|
+
if not overwrite and os.path.exists(file_path):
|
90
|
+
if verbose:
|
91
|
+
print(f"File {file_path} already exists, skipping download.")
|
92
|
+
return True
|
93
|
+
try:
|
94
|
+
blob = connector.get(src_url)
|
95
|
+
if not blob:
|
96
|
+
return False
|
97
|
+
pathlib.Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
98
|
+
with open(file_path, 'wb') as f:
|
99
|
+
f.write(blob)
|
100
|
+
break
|
101
|
+
except Exception as e:
|
102
|
+
if isinstance(e, KeyboardInterrupt):
|
103
|
+
raise e
|
104
|
+
if verbose:
|
105
|
+
print(f"Error downloading {src_url}: {e}, retrying...")
|
106
|
+
this_try += 1
|
107
|
+
finally:
|
108
|
+
time.sleep(interval)
|
109
|
+
|
110
|
+
if this_try > n_retries:
|
111
|
+
if verbose:
|
112
|
+
print(f"Failed to download {src_url} after {n_retries} retries.")
|
113
|
+
return False
|
114
|
+
return True
|
115
|
+
|
116
|
+
def download_directory(
|
117
|
+
connector: Connector,
|
118
|
+
src_path: str,
|
119
|
+
directory: str,
|
120
|
+
n_concurrent: int = 1,
|
121
|
+
n_retries: int = 0,
|
122
|
+
interval: float = 0,
|
123
|
+
verbose: bool = False,
|
124
|
+
overwrite: bool = False
|
125
|
+
) -> list[str]:
|
126
|
+
|
127
|
+
directory = str(directory)
|
128
|
+
|
129
|
+
if not src_path.endswith('/'):
|
130
|
+
src_path += '/'
|
131
|
+
if not directory.endswith(os.sep):
|
132
|
+
directory += os.sep
|
133
|
+
|
134
|
+
_counter = 0
|
135
|
+
_counter_lock = Lock()
|
136
|
+
failed_files = []
|
137
|
+
def get_file(src_url):
|
138
|
+
nonlocal _counter, failed_files
|
139
|
+
with _counter_lock:
|
140
|
+
_counter += 1
|
141
|
+
this_count = _counter
|
142
|
+
dst_path = f"{directory}{os.path.relpath(src_url, src_path)}"
|
143
|
+
if verbose:
|
144
|
+
print(f"[{this_count}] Downloading {src_url} to {dst_path}")
|
145
|
+
|
146
|
+
if not download_file(
|
147
|
+
connector, src_url, dst_path,
|
148
|
+
n_retries=n_retries, interval=interval, verbose=verbose, overwrite=overwrite
|
149
|
+
):
|
150
|
+
failed_files.append(src_url)
|
151
|
+
|
152
|
+
with ThreadPoolExecutor(n_concurrent) as executor:
|
153
|
+
for file in connector.list_path(src_path, flat=True).files:
|
154
|
+
executor.submit(get_file, file.url)
|
155
|
+
return failed_files
|
@@ -5,6 +5,7 @@ import urllib.parse
|
|
5
5
|
from lfss.src.datatype import (
|
6
6
|
FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents
|
7
7
|
)
|
8
|
+
from lfss.src.utils import ensure_uri_compnents
|
8
9
|
|
9
10
|
_default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
|
10
11
|
_default_token = os.environ.get('LFSS_TOKEN', '')
|
@@ -23,6 +24,7 @@ class Connector:
|
|
23
24
|
):
|
24
25
|
if path.startswith('/'):
|
25
26
|
path = path[1:]
|
27
|
+
path = ensure_uri_compnents(path)
|
26
28
|
def f(**kwargs):
|
27
29
|
url = f"{self.config['endpoint']}/{path}" + "?" + urllib.parse.urlencode(search_params)
|
28
30
|
headers: dict = kwargs.pop('headers', {})
|
@@ -115,9 +117,9 @@ class Connector:
|
|
115
117
|
return None
|
116
118
|
raise e
|
117
119
|
|
118
|
-
def list_path(self, path: str) -> PathContents:
|
120
|
+
def list_path(self, path: str, flat: bool = False) -> PathContents:
|
119
121
|
assert path.endswith('/')
|
120
|
-
response = self._fetch_factory('GET', path)()
|
122
|
+
response = self._fetch_factory('GET', path, {'flat': flat})()
|
121
123
|
dirs = [DirectoryRecord(**d) for d in response.json()['dirs']]
|
122
124
|
files = [FileRecord(**f) for f in response.json()['files']]
|
123
125
|
return PathContents(dirs=dirs, files=files)
|
@@ -1,5 +1,5 @@
|
|
1
1
|
from pathlib import Path
|
2
|
-
import os
|
2
|
+
import os
|
3
3
|
|
4
4
|
__default_dir = '.storage_data'
|
5
5
|
|
@@ -15,6 +15,3 @@ LARGE_BLOB_DIR.mkdir(exist_ok=True)
|
|
15
15
|
LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
|
16
16
|
MAX_FILE_BYTES = 512 * 1024 * 1024 # 512MB
|
17
17
|
MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
|
18
|
-
|
19
|
-
def hash_credential(username, password):
|
20
|
-
return hashlib.sha256((username + password).encode()).hexdigest()
|
@@ -18,13 +18,22 @@ async def execute_sql(conn: aiosqlite.Connection | aiosqlite.Cursor, name: str):
|
|
18
18
|
for s in sql:
|
19
19
|
await conn.execute(s)
|
20
20
|
|
21
|
-
async def get_connection() -> aiosqlite.Connection:
|
21
|
+
async def get_connection(read_only: bool = False) -> aiosqlite.Connection:
|
22
22
|
if not os.environ.get('SQLITE_TEMPDIR'):
|
23
23
|
os.environ['SQLITE_TEMPDIR'] = str(DATA_HOME)
|
24
|
-
|
25
|
-
|
24
|
+
|
25
|
+
def get_db_uri(path: Path, read_only: bool = False):
|
26
|
+
return f"file:{path}?mode={ 'ro' if read_only else 'rwc' }"
|
27
|
+
|
28
|
+
conn = await aiosqlite.connect(
|
29
|
+
get_db_uri(DATA_HOME / 'index.db', read_only=read_only),
|
30
|
+
timeout = 60, uri = True
|
31
|
+
)
|
26
32
|
async with conn.cursor() as c:
|
27
|
-
await c.execute(
|
33
|
+
await c.execute(
|
34
|
+
f"ATTACH DATABASE ? AS blobs",
|
35
|
+
(get_db_uri(DATA_HOME/'blobs.db', read_only=read_only), )
|
36
|
+
)
|
28
37
|
await execute_sql(conn, 'pragma.sql')
|
29
38
|
return conn
|
30
39
|
|
@@ -35,47 +44,49 @@ class SqlConnection:
|
|
35
44
|
is_available: bool = True
|
36
45
|
|
37
46
|
class SqlConnectionPool:
|
38
|
-
|
47
|
+
_r_sem: Semaphore
|
39
48
|
_w_sem: Semaphore
|
40
49
|
def __init__(self):
|
41
|
-
self.
|
42
|
-
self.
|
50
|
+
self._readers: list[SqlConnection] = []
|
51
|
+
self._writer: None | SqlConnection = None
|
43
52
|
self._lock = Lock()
|
44
53
|
|
45
54
|
async def init(self, n_read: int):
|
46
55
|
await self.close()
|
47
|
-
self.
|
48
|
-
|
49
|
-
|
50
|
-
self._connections.append(SqlConnection(conn))
|
51
|
-
self._w_connection = SqlConnection(await get_connection())
|
52
|
-
self._sem = Semaphore(n_read)
|
56
|
+
self._readers = []
|
57
|
+
|
58
|
+
self._writer = SqlConnection(await get_connection(read_only=False))
|
53
59
|
self._w_sem = Semaphore(1)
|
60
|
+
|
61
|
+
for _ in range(n_read):
|
62
|
+
conn = await get_connection(read_only=True)
|
63
|
+
self._readers.append(SqlConnection(conn))
|
64
|
+
self._r_sem = Semaphore(n_read)
|
54
65
|
|
55
66
|
@property
|
56
67
|
def n_read(self):
|
57
|
-
return len(self.
|
68
|
+
return len(self._readers)
|
58
69
|
@property
|
59
|
-
def
|
60
|
-
return self.
|
70
|
+
def r_sem(self):
|
71
|
+
return self._r_sem
|
61
72
|
@property
|
62
73
|
def w_sem(self):
|
63
74
|
return self._w_sem
|
64
75
|
|
65
76
|
async def get(self, w: bool = False) -> SqlConnection:
|
66
|
-
if len(self.
|
77
|
+
if len(self._readers) == 0:
|
67
78
|
raise Exception("No available connections, please init the pool first")
|
68
79
|
|
69
80
|
async with self._lock:
|
70
81
|
if w:
|
71
|
-
assert self.
|
72
|
-
if self.
|
73
|
-
self.
|
74
|
-
return self.
|
82
|
+
assert self._writer
|
83
|
+
if self._writer.is_available:
|
84
|
+
self._writer.is_available = False
|
85
|
+
return self._writer
|
75
86
|
raise Exception("Write connection is not available")
|
76
87
|
|
77
88
|
else:
|
78
|
-
for c in self.
|
89
|
+
for c in self._readers:
|
79
90
|
if c.is_available:
|
80
91
|
c.is_available = False
|
81
92
|
return c
|
@@ -83,19 +94,19 @@ class SqlConnectionPool:
|
|
83
94
|
|
84
95
|
async def release(self, conn: SqlConnection):
|
85
96
|
async with self._lock:
|
86
|
-
if conn == self.
|
97
|
+
if conn == self._writer:
|
87
98
|
conn.is_available = True
|
88
99
|
return
|
89
100
|
|
90
|
-
if not conn in self.
|
101
|
+
if not conn in self._readers:
|
91
102
|
raise Exception("Connection not in pool")
|
92
103
|
conn.is_available = True
|
93
104
|
|
94
105
|
async def close(self):
|
95
|
-
for c in self.
|
106
|
+
for c in self._readers:
|
96
107
|
await c.conn.close()
|
97
|
-
if self.
|
98
|
-
await self.
|
108
|
+
if self._writer:
|
109
|
+
await self._writer.conn.close()
|
99
110
|
|
100
111
|
# these two functions shold be called before and after the event loop
|
101
112
|
g_pool = SqlConnectionPool()
|
@@ -125,7 +136,7 @@ def global_entrance(n_read: int = 1):
|
|
125
136
|
@asynccontextmanager
|
126
137
|
async def unique_cursor(is_write: bool = False):
|
127
138
|
if not is_write:
|
128
|
-
async with g_pool.
|
139
|
+
async with g_pool.r_sem:
|
129
140
|
connection_obj = await g_pool.get()
|
130
141
|
try:
|
131
142
|
yield await connection_obj.conn.cursor()
|
@@ -1,5 +1,5 @@
|
|
1
1
|
|
2
|
-
from typing import Optional,
|
2
|
+
from typing import Optional, Literal, AsyncIterable
|
3
3
|
from abc import ABC
|
4
4
|
|
5
5
|
import urllib.parse
|
@@ -11,9 +11,9 @@ import aiofiles.os
|
|
11
11
|
|
12
12
|
from .connection_pool import execute_sql, unique_cursor, transaction
|
13
13
|
from .datatype import UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents
|
14
|
-
from .config import LARGE_BLOB_DIR
|
14
|
+
from .config import LARGE_BLOB_DIR
|
15
15
|
from .log import get_logger
|
16
|
-
from .utils import decode_uri_compnents
|
16
|
+
from .utils import decode_uri_compnents, hash_credential
|
17
17
|
from .error import *
|
18
18
|
|
19
19
|
class DBObjectBase(ABC):
|
@@ -29,9 +29,6 @@ class DBObjectBase(ABC):
|
|
29
29
|
raise ValueError("Connection not set")
|
30
30
|
return self._cur
|
31
31
|
|
32
|
-
# async def commit(self):
|
33
|
-
# await self.conn.commit()
|
34
|
-
|
35
32
|
DECOY_USER = UserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
|
36
33
|
class UserConn(DBObjectBase):
|
37
34
|
|
@@ -43,10 +40,6 @@ class UserConn(DBObjectBase):
|
|
43
40
|
def parse_record(record) -> UserRecord:
|
44
41
|
return UserRecord(*record)
|
45
42
|
|
46
|
-
async def init(self, cur: aiosqlite.Cursor):
|
47
|
-
self.set_cursor(cur)
|
48
|
-
return self
|
49
|
-
|
50
43
|
async def get_user(self, username: str) -> Optional[UserRecord]:
|
51
44
|
await self.cur.execute("SELECT * FROM user WHERE username = ?", (username, ))
|
52
45
|
res = await self.cur.fetchone()
|
@@ -132,10 +125,6 @@ class FileConn(DBObjectBase):
|
|
132
125
|
def parse_record(record) -> FileRecord:
|
133
126
|
return FileRecord(*record)
|
134
127
|
|
135
|
-
def init(self, cur: aiosqlite.Cursor):
|
136
|
-
self.set_cursor(cur)
|
137
|
-
return self
|
138
|
-
|
139
128
|
async def get_file_record(self, url: str) -> Optional[FileRecord]:
|
140
129
|
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url = ?", (url, ))
|
141
130
|
res = await cursor.fetchone()
|
@@ -150,7 +139,7 @@ class FileConn(DBObjectBase):
|
|
150
139
|
return []
|
151
140
|
return [self.parse_record(r) for r in res]
|
152
141
|
|
153
|
-
async def
|
142
|
+
async def list_root_dirs(self, *usernames: str) -> list[DirectoryRecord]:
|
154
143
|
"""
|
155
144
|
Efficiently list users' directories, if usernames is empty, list all users' directories.
|
156
145
|
"""
|
@@ -167,17 +156,12 @@ class FileConn(DBObjectBase):
|
|
167
156
|
dirs = [DirectoryRecord(u, await self.path_size(u, include_subpath=True)) for u in dirnames]
|
168
157
|
return dirs
|
169
158
|
|
170
|
-
|
171
|
-
async def list_path(self, url: str, flat: Literal[True]) -> list[FileRecord]:...
|
172
|
-
@overload
|
173
|
-
async def list_path(self, url: str, flat: Literal[False]) -> PathContents:...
|
174
|
-
|
175
|
-
async def list_path(self, url: str, flat: bool = False) -> list[FileRecord] | PathContents:
|
159
|
+
async def list_path(self, url: str, flat: bool = False) -> PathContents:
|
176
160
|
"""
|
177
|
-
List all files and directories under the given path
|
178
|
-
if flat is True,
|
179
|
-
Otherwise, return a tuple of (dirs, files), where dirs is a list of DirectoryRecord,
|
161
|
+
List all files and directories under the given path
|
162
|
+
if flat is True, list all files under the path, with out delimiting directories
|
180
163
|
"""
|
164
|
+
self.logger.debug(f"Listing path {url}, flat={flat}")
|
181
165
|
if not url.endswith('/'):
|
182
166
|
url += '/'
|
183
167
|
if url == '/':
|
@@ -186,15 +170,17 @@ class FileConn(DBObjectBase):
|
|
186
170
|
if flat:
|
187
171
|
cursor = await self.cur.execute("SELECT * FROM fmeta")
|
188
172
|
res = await cursor.fetchall()
|
189
|
-
|
173
|
+
files = [self.parse_record(r) for r in res]
|
174
|
+
return PathContents([], files)
|
190
175
|
|
191
176
|
else:
|
192
|
-
return PathContents(await self.
|
177
|
+
return PathContents(await self.list_root_dirs(), [])
|
193
178
|
|
194
179
|
if flat:
|
195
180
|
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', ))
|
196
181
|
res = await cursor.fetchall()
|
197
|
-
|
182
|
+
files = [self.parse_record(r) for r in res]
|
183
|
+
return PathContents([], files)
|
198
184
|
|
199
185
|
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%'))
|
200
186
|
res = await cursor.fetchall()
|
@@ -358,7 +344,7 @@ class FileConn(DBObjectBase):
|
|
358
344
|
await self._user_size_dec(r[0], size[0])
|
359
345
|
|
360
346
|
# if any new records are created here, the size update may be inconsistent
|
361
|
-
# but it's not a big deal...
|
347
|
+
# but it's not a big deal... we should have only one writer
|
362
348
|
|
363
349
|
if under_user_id is None:
|
364
350
|
res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? RETURNING *", (path + '%', ))
|
@@ -432,7 +418,7 @@ async def get_user(cur: aiosqlite.Cursor, user: int | str) -> Optional[UserRecor
|
|
432
418
|
else:
|
433
419
|
return None
|
434
420
|
|
435
|
-
# mostly transactional
|
421
|
+
# higher level database operations, mostly transactional
|
436
422
|
class Database:
|
437
423
|
logger = get_logger('database', global_instance=True)
|
438
424
|
|
@@ -615,11 +601,13 @@ class Database:
|
|
615
601
|
else:
|
616
602
|
internal_ids.append(r.file_id)
|
617
603
|
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
604
|
+
async def del_internal():
|
605
|
+
for i in range(0, len(internal_ids), batch_size):
|
606
|
+
await fconn.delete_file_blobs([r for r in internal_ids[i:i+batch_size]])
|
607
|
+
async def del_external():
|
608
|
+
for i in range(0, len(external_ids)):
|
609
|
+
await fconn.delete_file_blob_external(external_ids[i])
|
610
|
+
await asyncio.gather(del_internal(), del_external())
|
623
611
|
|
624
612
|
async def delete_path(self, url: str, under_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
|
625
613
|
validate_url(url, is_file=False)
|
@@ -655,7 +643,7 @@ class Database:
|
|
655
643
|
async with unique_cursor() as cur:
|
656
644
|
fconn = FileConn(cur)
|
657
645
|
if urls is None:
|
658
|
-
urls = [r.url for r in await fconn.list_path(top_url, flat=True)]
|
646
|
+
urls = [r.url for r in (await fconn.list_path(top_url, flat=True)).files]
|
659
647
|
|
660
648
|
for url in urls:
|
661
649
|
if not url.startswith(top_url):
|
@@ -151,9 +151,6 @@ def log_access(
|
|
151
151
|
return wrapper # type: ignore
|
152
152
|
return _log_access
|
153
153
|
|
154
|
-
def get_dummy_logger() -> BaseLogger:
|
155
|
-
return BaseLogger('dummy')
|
156
|
-
|
157
154
|
__ALL__ = [
|
158
|
-
'get_logger', '
|
155
|
+
'get_logger', 'log_access'
|
159
156
|
]
|
@@ -18,7 +18,7 @@ from .stat import RequestDB
|
|
18
18
|
from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_FILE_BYTES
|
19
19
|
from .utils import ensure_uri_compnents, format_last_modified, now_stamp
|
20
20
|
from .connection_pool import global_connection_init, global_connection_close, unique_cursor
|
21
|
-
from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission, UserConn, FileConn
|
21
|
+
from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission, UserConn, FileConn, PathContents
|
22
22
|
|
23
23
|
logger = get_logger("server", term_level="DEBUG")
|
24
24
|
logger_failed_request = get_logger("failed_requests", term_level="INFO")
|
@@ -120,7 +120,7 @@ router_fs = APIRouter(prefix="")
|
|
120
120
|
|
121
121
|
@router_fs.get("/{path:path}")
|
122
122
|
@handle_exception
|
123
|
-
async def get_file(path: str, download = False, user: UserRecord = Depends(get_current_user)):
|
123
|
+
async def get_file(path: str, download: bool = False, flat: bool = False, user: UserRecord = Depends(get_current_user)):
|
124
124
|
path = ensure_uri_compnents(path)
|
125
125
|
|
126
126
|
# handle directory query
|
@@ -130,18 +130,20 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
|
|
130
130
|
async with unique_cursor() as conn:
|
131
131
|
fconn = FileConn(conn)
|
132
132
|
if user.id == 0:
|
133
|
-
raise HTTPException(status_code=
|
133
|
+
raise HTTPException(status_code=401, detail="Permission denied, credential required")
|
134
134
|
if path == "/":
|
135
|
-
|
136
|
-
"
|
137
|
-
|
138
|
-
|
139
|
-
|
135
|
+
if flat:
|
136
|
+
raise HTTPException(status_code=400, detail="Flat query not supported for root path")
|
137
|
+
return PathContents(
|
138
|
+
dirs = await fconn.list_root_dirs(user.username) \
|
139
|
+
if not user.is_admin else await fconn.list_root_dirs(),
|
140
|
+
files = []
|
141
|
+
)
|
140
142
|
|
141
143
|
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
142
144
|
raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
|
143
145
|
|
144
|
-
return await fconn.list_path(path, flat =
|
146
|
+
return await fconn.list_path(path, flat = flat)
|
145
147
|
|
146
148
|
async with unique_cursor() as conn:
|
147
149
|
fconn = FileConn(conn)
|
@@ -217,7 +219,6 @@ async def put_file(
|
|
217
219
|
return Response(status_code=200, headers={
|
218
220
|
"Content-Type": "application/json",
|
219
221
|
}, content=json.dumps({"url": path}))
|
220
|
-
# remove the old file
|
221
222
|
exists_flag = True
|
222
223
|
if not user.is_admin and not file_record.owner_id == user.id:
|
223
224
|
raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
|
@@ -317,7 +318,7 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
|
|
317
318
|
|
318
319
|
async with unique_cursor() as conn:
|
319
320
|
fconn = FileConn(conn)
|
320
|
-
files = await fconn.list_path(path, flat = True)
|
321
|
+
files = (await fconn.list_path(path, flat = True)).files
|
321
322
|
files = [f for f in files if await is_access_granted(f)]
|
322
323
|
if len(files) == 0:
|
323
324
|
raise HTTPException(status_code=404, detail="No files found")
|
@@ -341,13 +342,24 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
|
|
341
342
|
async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
|
342
343
|
logger.info(f"GET meta({path}), user: {user.username}")
|
343
344
|
path = ensure_uri_compnents(path)
|
345
|
+
is_file = not path.endswith("/")
|
344
346
|
async with unique_cursor() as conn:
|
345
347
|
fconn = FileConn(conn)
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
348
|
+
if is_file:
|
349
|
+
record = await fconn.get_file_record(path)
|
350
|
+
if not record:
|
351
|
+
raise HTTPException(status_code=404, detail="File not found")
|
352
|
+
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
353
|
+
uconn = UserConn(conn)
|
354
|
+
owner = await uconn.get_user_by_id(record.owner_id)
|
355
|
+
assert owner is not None, "Owner not found"
|
356
|
+
is_allowed, reason = check_user_permission(user, owner, record)
|
357
|
+
if not is_allowed:
|
358
|
+
raise HTTPException(status_code=403, detail=reason)
|
359
|
+
else:
|
360
|
+
record = await fconn.get_path_record(path)
|
361
|
+
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
362
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
351
363
|
return record
|
352
364
|
|
353
365
|
@router_api.post("/meta")
|
@@ -2,35 +2,27 @@ import datetime
|
|
2
2
|
import urllib.parse
|
3
3
|
import asyncio
|
4
4
|
import functools
|
5
|
+
import hashlib
|
6
|
+
|
7
|
+
def hash_credential(username: str, password: str):
|
8
|
+
return hashlib.sha256((username + password).encode()).hexdigest()
|
5
9
|
|
6
10
|
def encode_uri_compnents(path: str):
|
7
|
-
"""
|
8
|
-
Encode the path components to encode the special characters,
|
9
|
-
also to avoid path traversal attack
|
10
|
-
"""
|
11
11
|
path_sp = path.split("/")
|
12
12
|
mapped = map(lambda x: urllib.parse.quote(x), path_sp)
|
13
13
|
return "/".join(mapped)
|
14
14
|
|
15
15
|
def decode_uri_compnents(path: str):
|
16
|
-
"""
|
17
|
-
Decode the path components to decode the special characters
|
18
|
-
"""
|
19
16
|
path_sp = path.split("/")
|
20
17
|
mapped = map(lambda x: urllib.parse.unquote(x), path_sp)
|
21
18
|
return "/".join(mapped)
|
22
19
|
|
23
20
|
def ensure_uri_compnents(path: str):
|
24
|
-
"""
|
25
|
-
Ensure the path components are safe to use
|
26
|
-
"""
|
21
|
+
""" Ensure the path components are safe to use """
|
27
22
|
return encode_uri_compnents(decode_uri_compnents(path))
|
28
23
|
|
29
24
|
def debounce_async(delay: float = 0):
|
30
|
-
"""
|
31
|
-
Decorator to debounce the async function (procedure)
|
32
|
-
The function must return None
|
33
|
-
"""
|
25
|
+
""" Debounce the async procedure """
|
34
26
|
def debounce_wrap(func):
|
35
27
|
# https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancel
|
36
28
|
async def delayed_func(*args, **kwargs):
|
@@ -51,10 +43,9 @@ def debounce_async(delay: float = 0):
|
|
51
43
|
return wrapper
|
52
44
|
return debounce_wrap
|
53
45
|
|
54
|
-
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
|
55
46
|
def format_last_modified(last_modified_gmt: str):
|
56
47
|
"""
|
57
|
-
Format the last modified time to the HTTP standard format
|
48
|
+
Format the last modified time to the [HTTP standard format](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified)
|
58
49
|
- last_modified_gmt: The last modified time in SQLite ISO 8601 GMT format: e.g. '2021-09-01 12:00:00'
|
59
50
|
"""
|
60
51
|
assert len(last_modified_gmt) == 19
|
lfss-0.7.3/lfss/cli/cli.py
DELETED
@@ -1,52 +0,0 @@
|
|
1
|
-
from lfss.client import Connector, upload_directory
|
2
|
-
from lfss.src.datatype import FileReadPermission
|
3
|
-
from pathlib import Path
|
4
|
-
import argparse
|
5
|
-
|
6
|
-
def parse_arguments():
|
7
|
-
parser = argparse.ArgumentParser(description="Command line interface, please set LFSS_ENDPOINT and LFSS_TOKEN environment variables.")
|
8
|
-
|
9
|
-
sp = parser.add_subparsers(dest="command", required=True)
|
10
|
-
|
11
|
-
# upload
|
12
|
-
sp_upload = sp.add_parser("upload", help="Upload files")
|
13
|
-
sp_upload.add_argument("src", help="Source file or directory", type=str)
|
14
|
-
sp_upload.add_argument("dst", help="Destination path", type=str)
|
15
|
-
sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
|
16
|
-
sp_upload.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory upload")
|
17
|
-
sp_upload.add_argument("--conflict", choices=["overwrite", "abort", "skip", "skip-ahead"], default="abort", help="Conflict resolution")
|
18
|
-
sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
|
19
|
-
sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
|
20
|
-
|
21
|
-
return parser.parse_args()
|
22
|
-
|
23
|
-
def main():
|
24
|
-
args = parse_arguments()
|
25
|
-
connector = Connector()
|
26
|
-
if args.command == "upload":
|
27
|
-
src_path = Path(args.src)
|
28
|
-
if src_path.is_dir():
|
29
|
-
failed_upload = upload_directory(
|
30
|
-
connector, args.src, args.dst,
|
31
|
-
verbose=True,
|
32
|
-
n_concurrent=args.jobs,
|
33
|
-
n_reties=args.retries,
|
34
|
-
interval=args.interval,
|
35
|
-
conflict=args.conflict,
|
36
|
-
permission=args.permission
|
37
|
-
)
|
38
|
-
if failed_upload:
|
39
|
-
print("Failed to upload:")
|
40
|
-
for path in failed_upload:
|
41
|
-
print(f" {path}")
|
42
|
-
else:
|
43
|
-
with open(args.src, 'rb') as f:
|
44
|
-
connector.put(
|
45
|
-
args.dst,
|
46
|
-
f.read(),
|
47
|
-
conflict=args.conflict,
|
48
|
-
permission=args.permission
|
49
|
-
)
|
50
|
-
else:
|
51
|
-
raise NotImplementedError(f"Command {args.command} not implemented.")
|
52
|
-
|
@@ -1,60 +0,0 @@
|
|
1
|
-
import os, time
|
2
|
-
from threading import Lock
|
3
|
-
from concurrent.futures import ThreadPoolExecutor
|
4
|
-
from .api import Connector
|
5
|
-
|
6
|
-
def upload_directory(
|
7
|
-
connector: Connector,
|
8
|
-
directory: str,
|
9
|
-
path: str,
|
10
|
-
n_concurrent: int = 1,
|
11
|
-
n_reties: int = 0,
|
12
|
-
interval: float = 0,
|
13
|
-
verbose: bool = False,
|
14
|
-
**put_kwargs
|
15
|
-
) -> list[str]:
|
16
|
-
assert path.endswith('/'), "Path must end with a slash."
|
17
|
-
if path.startswith('/'):
|
18
|
-
path = path[1:]
|
19
|
-
|
20
|
-
_counter = 0
|
21
|
-
_counter_lock = Lock()
|
22
|
-
|
23
|
-
faild_files = []
|
24
|
-
def put_file(file_path):
|
25
|
-
with _counter_lock:
|
26
|
-
nonlocal _counter
|
27
|
-
_counter += 1
|
28
|
-
this_count = _counter
|
29
|
-
dst_path = f"{path}{os.path.relpath(file_path, directory)}"
|
30
|
-
if verbose:
|
31
|
-
print(f"[{this_count}] Uploading {file_path} to {dst_path}")
|
32
|
-
|
33
|
-
this_try = 0
|
34
|
-
with open(file_path, 'rb') as f:
|
35
|
-
blob = f.read()
|
36
|
-
|
37
|
-
while this_try <= n_reties:
|
38
|
-
try:
|
39
|
-
connector.put(dst_path, blob, **put_kwargs)
|
40
|
-
break
|
41
|
-
except Exception as e:
|
42
|
-
if isinstance(e, KeyboardInterrupt):
|
43
|
-
raise e
|
44
|
-
if verbose:
|
45
|
-
print(f"[{this_count}] Error uploading {file_path}: {e}, retrying...")
|
46
|
-
this_try += 1
|
47
|
-
finally:
|
48
|
-
time.sleep(interval)
|
49
|
-
|
50
|
-
if this_try > n_reties:
|
51
|
-
faild_files.append(file_path)
|
52
|
-
if verbose:
|
53
|
-
print(f"[{this_count}] Failed to upload {file_path} after {n_reties} retries.")
|
54
|
-
|
55
|
-
with ThreadPoolExecutor(n_concurrent) as executor:
|
56
|
-
for root, dirs, files in os.walk(directory):
|
57
|
-
for file in files:
|
58
|
-
executor.submit(put_file, os.path.join(root, file))
|
59
|
-
|
60
|
-
return faild_files
|
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
|