lfss 0.8.3__tar.gz → 0.9.0__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.8.3 → lfss-0.9.0}/PKG-INFO +13 -5
- {lfss-0.8.3 → lfss-0.9.0}/Readme.md +12 -4
- lfss-0.9.0/docs/Permission.md +58 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/api.js +21 -10
- {lfss-0.8.3 → lfss-0.9.0}/frontend/thumb.js +8 -4
- {lfss-0.8.3 → lfss-0.9.0}/lfss/api/connector.py +10 -6
- {lfss-0.8.3 → lfss-0.9.0}/lfss/cli/cli.py +6 -10
- {lfss-0.8.3 → lfss-0.9.0}/lfss/cli/user.py +28 -1
- {lfss-0.8.3 → lfss-0.9.0}/lfss/sql/init.sql +9 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/connection_pool.py +22 -3
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/database.py +184 -66
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/datatype.py +18 -3
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/error.py +3 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/server.py +50 -67
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/utils.py +9 -6
- {lfss-0.8.3 → lfss-0.9.0}/pyproject.toml +1 -1
- lfss-0.8.3/docs/Permission.md +0 -46
- {lfss-0.8.3 → lfss-0.9.0}/docs/Known_issues.md +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/index.html +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/info.css +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/info.js +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/login.css +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/login.js +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/popup.css +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/popup.js +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/scripts.js +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/state.js +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/styles.css +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/thumb.css +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/frontend/utils.js +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/api/__init__.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/cli/__init__.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/cli/balance.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/cli/panel.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/cli/serve.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/cli/vacuum.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/sql/pragma.sql +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/__init__.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/bounded_pool.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/config.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/log.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/stat.py +0 -0
- {lfss-0.8.3 → lfss-0.9.0}/lfss/src/thumb.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.9.0
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li, mengxun
|
@@ -24,9 +24,17 @@ Description-Content-Type: text/markdown
|
|
24
24
|
# Lightweight File Storage Service (LFSS)
|
25
25
|
[](https://pypi.org/project/lfss/)
|
26
26
|
|
27
|
-
My experiment on a lightweight and high-performance file/object storage service
|
27
|
+
My experiment on a lightweight and high-performance file/object storage service...
|
28
|
+
|
29
|
+
**Highlights:**
|
30
|
+
|
31
|
+
- User storage limit and access control.
|
32
|
+
- Pagination and sorted file listing for vast number of files.
|
33
|
+
- High performance: high concurrency, near-native speed on stress tests.
|
34
|
+
- Support range requests, so you can stream large files / resume download.
|
35
|
+
|
28
36
|
It stores small files and metadata in sqlite, large files in the filesystem.
|
29
|
-
Tested on 2 million files, and it
|
37
|
+
Tested on 2 million files, and it is still fast.
|
30
38
|
|
31
39
|
Usage:
|
32
40
|
```sh
|
@@ -45,8 +53,8 @@ lfss-panel --open
|
|
45
53
|
Or, you can start a web server at `/frontend` and open `index.html` in your browser.
|
46
54
|
|
47
55
|
The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
|
48
|
-
Authentication
|
49
|
-
You can refer to `frontend` as an application example,
|
56
|
+
Authentication via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
|
57
|
+
You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
|
50
58
|
|
51
59
|
By default, the service exposes all files to the public for `GET` requests,
|
52
60
|
but file-listing is restricted to the user's own files.
|
@@ -1,9 +1,17 @@
|
|
1
1
|
# Lightweight File Storage Service (LFSS)
|
2
2
|
[](https://pypi.org/project/lfss/)
|
3
3
|
|
4
|
-
My experiment on a lightweight and high-performance file/object storage service
|
4
|
+
My experiment on a lightweight and high-performance file/object storage service...
|
5
|
+
|
6
|
+
**Highlights:**
|
7
|
+
|
8
|
+
- User storage limit and access control.
|
9
|
+
- Pagination and sorted file listing for vast number of files.
|
10
|
+
- High performance: high concurrency, near-native speed on stress tests.
|
11
|
+
- Support range requests, so you can stream large files / resume download.
|
12
|
+
|
5
13
|
It stores small files and metadata in sqlite, large files in the filesystem.
|
6
|
-
Tested on 2 million files, and it
|
14
|
+
Tested on 2 million files, and it is still fast.
|
7
15
|
|
8
16
|
Usage:
|
9
17
|
```sh
|
@@ -22,8 +30,8 @@ lfss-panel --open
|
|
22
30
|
Or, you can start a web server at `/frontend` and open `index.html` in your browser.
|
23
31
|
|
24
32
|
The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
|
25
|
-
Authentication
|
26
|
-
You can refer to `frontend` as an application example,
|
33
|
+
Authentication via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
|
34
|
+
You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
|
27
35
|
|
28
36
|
By default, the service exposes all files to the public for `GET` requests,
|
29
37
|
but file-listing is restricted to the user's own files.
|
@@ -0,0 +1,58 @@
|
|
1
|
+
|
2
|
+
# Permission System
|
3
|
+
There are two user roles in the system: Admin and Normal User ("users" are like "buckets" to some extent).
|
4
|
+
A user have all permissions of the files and subpaths under its path (starting with `/<user>/`).
|
5
|
+
Admins have all permissions of all files and paths.
|
6
|
+
|
7
|
+
> **path** ends with `/` and **file** does not end with `/`.
|
8
|
+
|
9
|
+
## Peers
|
10
|
+
The user can have multiple peer users. The peer user can have read or write access to the user's path, depending on the access level set when adding the peer user.
|
11
|
+
The peer user can list the files under the user's path.
|
12
|
+
If the peer user only has read access (peer-r), then the peer user can only `GET` files under the user's path.
|
13
|
+
If the peer user has write access (peer-w), then the peer user can `GET`/`PUT`/`POST`/`DELETE` files under the user's path.
|
14
|
+
|
15
|
+
## Ownership
|
16
|
+
A file is owned by the user who created it, may not necessarily be the user under whose path the file is stored (admin/write-peer can create files under any user's path).
|
17
|
+
|
18
|
+
# Non-peer and public access
|
19
|
+
|
20
|
+
**NOTE:** below discussion is based on the assumption that the user is not a peer of the path owner, or is guest user (public access).
|
21
|
+
|
22
|
+
## File access with `GET` permission
|
23
|
+
|
24
|
+
### File access
|
25
|
+
For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the owner and the file.
|
26
|
+
|
27
|
+
There are four types of permissions: `unset`, `public`, `protected`, `private`.
|
28
|
+
Non-admin users can access files based on:
|
29
|
+
|
30
|
+
- If the file is `public`, then all users can access it.
|
31
|
+
- If the file is `protected`, then only the logged-in user can access it.
|
32
|
+
- If the file is `private`, then only the owner can access it.
|
33
|
+
- If the file is `unset`, then the file's permission is inherited from the owner's permission.
|
34
|
+
- If both the owner and the file have `unset` permission, then the file is `public`.
|
35
|
+
|
36
|
+
## File creation with `PUT`/`POST` permission
|
37
|
+
`PUT`/`POST` permission is not allowed for non-peer users.
|
38
|
+
|
39
|
+
## File `DELETE` and moving permissions
|
40
|
+
- Non-login user don't have `DELETE`/move permission.
|
41
|
+
- Every user can have `DELETE` permission that they own.
|
42
|
+
- User can move files if they have write access to the destination path.
|
43
|
+
|
44
|
+
## Path-listing
|
45
|
+
Path-listing is not allowed for these users.
|
46
|
+
|
47
|
+
# Summary
|
48
|
+
|
49
|
+
| Permission | Admin | User | Peer-r | Peer-w | Owner (not the user) | Non-peer user / Guest |
|
50
|
+
|------------|-------|------|--------|--------|----------------------|------------------------|
|
51
|
+
| GET | Yes | Yes | Yes | Yes | Yes | Depends on file |
|
52
|
+
| PUT/POST | Yes | Yes | No | Yes | Yes | No |
|
53
|
+
| DELETE file| Yes | Yes | No | Yes | Yes | No |
|
54
|
+
| DELETE path| Yes | Yes | No | Yes | N/A | No |
|
55
|
+
| move | Yes | Yes | No | Yes | Dep. on destination | No |
|
56
|
+
| list | Yes | Yes | Yes | Yes | No if not peer | No |
|
57
|
+
|
58
|
+
> Capitilized methods are HTTP methods, N/A means not applicable.
|
@@ -45,6 +45,17 @@ export const permMap = {
|
|
45
45
|
3: 'private'
|
46
46
|
}
|
47
47
|
|
48
|
+
async function fmtFailedResponse(res){
|
49
|
+
const raw = await res.text();
|
50
|
+
const json = raw ? JSON.parse(raw) : {};
|
51
|
+
const txt = JSON.stringify(json.detail || json || "No message");
|
52
|
+
const maxWords = 32;
|
53
|
+
if (txt.length > maxWords){
|
54
|
+
return txt.slice(0, maxWords) + '...';
|
55
|
+
}
|
56
|
+
return txt;
|
57
|
+
}
|
58
|
+
|
48
59
|
export default class Connector {
|
49
60
|
|
50
61
|
constructor(){
|
@@ -79,7 +90,7 @@ export default class Connector {
|
|
79
90
|
body: fileBytes
|
80
91
|
});
|
81
92
|
if (res.status != 200 && res.status != 201){
|
82
|
-
throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await res
|
93
|
+
throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
83
94
|
}
|
84
95
|
return (await res.json()).url;
|
85
96
|
}
|
@@ -111,7 +122,7 @@ export default class Connector {
|
|
111
122
|
});
|
112
123
|
|
113
124
|
if (res.status != 200 && res.status != 201){
|
114
|
-
throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await res
|
125
|
+
throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
115
126
|
}
|
116
127
|
return (await res.json()).url;
|
117
128
|
}
|
@@ -133,7 +144,7 @@ export default class Connector {
|
|
133
144
|
body: JSON.stringify(data)
|
134
145
|
});
|
135
146
|
if (res.status != 200 && res.status != 201){
|
136
|
-
throw new Error(`Failed to upload object, status code: ${res.status}, message: ${await res
|
147
|
+
throw new Error(`Failed to upload object, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
137
148
|
}
|
138
149
|
return (await res.json()).url;
|
139
150
|
}
|
@@ -147,7 +158,7 @@ export default class Connector {
|
|
147
158
|
},
|
148
159
|
});
|
149
160
|
if (res.status == 200) return;
|
150
|
-
throw new Error(`Failed to delete file, status code: ${res.status}, message: ${await res
|
161
|
+
throw new Error(`Failed to delete file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
151
162
|
}
|
152
163
|
|
153
164
|
/**
|
@@ -211,7 +222,7 @@ export default class Connector {
|
|
211
222
|
},
|
212
223
|
});
|
213
224
|
if (res.status != 200){
|
214
|
-
throw new Error(`Failed to count files, status code: ${res.status}, message: ${await res
|
225
|
+
throw new Error(`Failed to count files, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
215
226
|
}
|
216
227
|
return (await res.json()).count;
|
217
228
|
}
|
@@ -250,7 +261,7 @@ export default class Connector {
|
|
250
261
|
},
|
251
262
|
});
|
252
263
|
if (res.status != 200){
|
253
|
-
throw new Error(`Failed to list files, status code: ${res.status}, message: ${await res
|
264
|
+
throw new Error(`Failed to list files, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
254
265
|
}
|
255
266
|
return await res.json();
|
256
267
|
}
|
@@ -270,7 +281,7 @@ export default class Connector {
|
|
270
281
|
},
|
271
282
|
});
|
272
283
|
if (res.status != 200){
|
273
|
-
throw new Error(`Failed to count directories, status code: ${res.status}, message: ${await res
|
284
|
+
throw new Error(`Failed to count directories, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
274
285
|
}
|
275
286
|
return (await res.json()).count;
|
276
287
|
}
|
@@ -309,7 +320,7 @@ export default class Connector {
|
|
309
320
|
},
|
310
321
|
});
|
311
322
|
if (res.status != 200){
|
312
|
-
throw new Error(`Failed to list directories, status code: ${res.status}, message: ${await res
|
323
|
+
throw new Error(`Failed to list directories, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
313
324
|
}
|
314
325
|
return await res.json();
|
315
326
|
}
|
@@ -347,7 +358,7 @@ export default class Connector {
|
|
347
358
|
},
|
348
359
|
});
|
349
360
|
if (res.status != 200){
|
350
|
-
throw new Error(`Failed to set permission, status code: ${res.status}, message: ${await res
|
361
|
+
throw new Error(`Failed to set permission, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
351
362
|
}
|
352
363
|
}
|
353
364
|
|
@@ -369,7 +380,7 @@ export default class Connector {
|
|
369
380
|
},
|
370
381
|
});
|
371
382
|
if (res.status != 200){
|
372
|
-
throw new Error(`Failed to move file, status code: ${res.status}, message: ${await res
|
383
|
+
throw new Error(`Failed to move file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
373
384
|
}
|
374
385
|
}
|
375
386
|
|
@@ -39,10 +39,14 @@ function getIconSVGFromMimeType(mimeType){
|
|
39
39
|
return ICON_ZIP;
|
40
40
|
}
|
41
41
|
if ([
|
42
|
-
"text/html", "application/xhtml+xml", "application/xml", "text/css", "
|
43
|
-
"application/
|
44
|
-
"text/x-
|
45
|
-
"
|
42
|
+
"text/html", "application/xhtml+xml", "application/xml", "text/css", "text/x-scss", "application/javascript", "text/javascript",
|
43
|
+
"application/json", "text/x-yaml", "text/x-markdown", "application/wasm",
|
44
|
+
"text/x-ruby", "application/x-ruby", "text/x-perl", "application/x-lisp",
|
45
|
+
"text/x-haskell", "text/x-lua", "application/x-tcl",
|
46
|
+
"text/x-python", "text/x-java-source", "text/x-go", "application/x-rust", "text/x-asm",
|
47
|
+
"application/sql", "text/x-c", "text/x-c++", "text/x-csharp",
|
48
|
+
"application/x-httpd-php", "application/x-sh", "application/x-shellscript",
|
49
|
+
"application/x-latex", "application/x-tex",
|
46
50
|
].includes(mimeType)){
|
47
51
|
return ICON_CODE;
|
48
52
|
}
|
@@ -17,14 +17,20 @@ _default_token = os.environ.get('LFSS_TOKEN', '')
|
|
17
17
|
|
18
18
|
class Connector:
|
19
19
|
class Session:
|
20
|
-
def __init__(
|
20
|
+
def __init__(
|
21
|
+
self, connector: Connector, pool_size: int = 10,
|
22
|
+
retry: int = 1, backoff_factor: float = 0.5, status_forcelist: list[int] = [503]
|
23
|
+
):
|
21
24
|
self.connector = connector
|
22
25
|
self.pool_size = pool_size
|
26
|
+
self.retry_adapter = requests.adapters.Retry(
|
27
|
+
total=retry, backoff_factor=backoff_factor, status_forcelist=status_forcelist,
|
28
|
+
)
|
23
29
|
def open(self):
|
24
30
|
self.close()
|
25
31
|
if self.connector._session is None:
|
26
32
|
s = requests.Session()
|
27
|
-
adapter = requests.adapters.HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size)
|
33
|
+
adapter = requests.adapters.HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size, max_retries=self.retry_adapter)
|
28
34
|
s.mount('http://', adapter)
|
29
35
|
s.mount('https://', adapter)
|
30
36
|
self.connector._session = s
|
@@ -48,9 +54,9 @@ class Connector:
|
|
48
54
|
}
|
49
55
|
self._session: Optional[requests.Session] = None
|
50
56
|
|
51
|
-
def session(self, pool_size: int = 10):
|
57
|
+
def session( self, pool_size: int = 10, **kwargs):
|
52
58
|
""" avoid creating a new session for each request. """
|
53
|
-
return self.Session(self, pool_size)
|
59
|
+
return self.Session(self, pool_size, **kwargs)
|
54
60
|
|
55
61
|
def _fetch_factory(
|
56
62
|
self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
|
@@ -114,7 +120,6 @@ class Connector:
|
|
114
120
|
|
115
121
|
if isinstance(file, str):
|
116
122
|
assert os.path.exists(file), "File does not exist on disk"
|
117
|
-
fsize = os.path.getsize(file)
|
118
123
|
|
119
124
|
with open(file, 'rb') if isinstance(file, str) else SpooledTemporaryFile(max_size=1024*1024*32) as fp:
|
120
125
|
|
@@ -124,7 +129,6 @@ class Connector:
|
|
124
129
|
fp.seek(0)
|
125
130
|
|
126
131
|
# https://stackoverflow.com/questions/12385179/
|
127
|
-
print(f"Uploading {fsize} bytes")
|
128
132
|
response = self._fetch_factory('POST', path, search_params={
|
129
133
|
'permission': int(permission),
|
130
134
|
'conflict': conflict
|
@@ -1,24 +1,18 @@
|
|
1
|
-
from lfss.api import Connector, upload_directory, upload_file, download_file, download_directory
|
2
1
|
from pathlib import Path
|
3
2
|
import argparse, typing
|
3
|
+
from lfss.api import Connector, upload_directory, upload_file, download_file, download_directory
|
4
4
|
from lfss.src.datatype import FileReadPermission, FileSortKey, DirSortKey
|
5
5
|
from lfss.src.utils import decode_uri_compnents
|
6
6
|
from . import catch_request_error, line_sep
|
7
7
|
|
8
8
|
def parse_permission(s: str) -> FileReadPermission:
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
return FileReadPermission.PROTECTED
|
13
|
-
if s.lower() == "private":
|
14
|
-
return FileReadPermission.PRIVATE
|
15
|
-
if s.lower() == "unset":
|
16
|
-
return FileReadPermission.UNSET
|
9
|
+
for p in FileReadPermission:
|
10
|
+
if p.name.lower() == s.lower():
|
11
|
+
return p
|
17
12
|
raise ValueError(f"Invalid permission {s}")
|
18
13
|
|
19
14
|
def parse_arguments():
|
20
15
|
parser = argparse.ArgumentParser(description="Command line interface, please set LFSS_ENDPOINT and LFSS_TOKEN environment variables.")
|
21
|
-
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
22
16
|
|
23
17
|
sp = parser.add_subparsers(dest="command", required=True)
|
24
18
|
|
@@ -26,6 +20,7 @@ def parse_arguments():
|
|
26
20
|
sp_upload = sp.add_parser("upload", help="Upload files")
|
27
21
|
sp_upload.add_argument("src", help="Source file or directory", type=str)
|
28
22
|
sp_upload.add_argument("dst", help="Destination url path", type=str)
|
23
|
+
sp_upload.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
29
24
|
sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
|
30
25
|
sp_upload.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory upload")
|
31
26
|
sp_upload.add_argument("--conflict", choices=["overwrite", "abort", "skip", "skip-ahead"], default="abort", help="Conflict resolution")
|
@@ -36,6 +31,7 @@ def parse_arguments():
|
|
36
31
|
sp_download = sp.add_parser("download", help="Download files")
|
37
32
|
sp_download.add_argument("src", help="Source url path", type=str)
|
38
33
|
sp_download.add_argument("dst", help="Destination file or directory", type=str)
|
34
|
+
sp_download.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
39
35
|
sp_download.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent downloads")
|
40
36
|
sp_download.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory download")
|
41
37
|
sp_download.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
|
@@ -2,9 +2,16 @@ import argparse, asyncio, os
|
|
2
2
|
from contextlib import asynccontextmanager
|
3
3
|
from .cli import parse_permission, FileReadPermission
|
4
4
|
from ..src.utils import parse_storage_size, fmt_storage_size
|
5
|
+
from ..src.datatype import AccessLevel
|
5
6
|
from ..src.database import Database, FileReadPermission, transaction, UserConn, unique_cursor, FileConn
|
6
7
|
from ..src.connection_pool import global_entrance
|
7
8
|
|
9
|
+
def parse_access_level(s: str) -> AccessLevel:
|
10
|
+
for p in AccessLevel:
|
11
|
+
if p.name.lower() == s.lower():
|
12
|
+
return p
|
13
|
+
raise ValueError(f"Invalid access level {s}")
|
14
|
+
|
8
15
|
@global_entrance(1)
|
9
16
|
async def _main():
|
10
17
|
parser = argparse.ArgumentParser()
|
@@ -31,11 +38,16 @@ async def _main():
|
|
31
38
|
sp_set.add_argument('-a', '--admin', type=parse_bool, default=None)
|
32
39
|
sp_set.add_argument('--permission', type=parse_permission, default=None)
|
33
40
|
sp_set.add_argument('--max-storage', type=parse_storage_size, default=None)
|
34
|
-
|
41
|
+
|
35
42
|
sp_list = sp.add_parser('list')
|
36
43
|
sp_list.add_argument("username", nargs='*', type=str, default=None)
|
37
44
|
sp_list.add_argument("-l", "--long", action="store_true")
|
38
45
|
|
46
|
+
sp_peer = sp.add_parser('set-peer')
|
47
|
+
sp_peer.add_argument('src_username', type=str)
|
48
|
+
sp_peer.add_argument('dst_username', type=str)
|
49
|
+
sp_peer.add_argument('--level', type=parse_access_level, default=AccessLevel.READ, help="Access level")
|
50
|
+
|
39
51
|
args = parser.parse_args()
|
40
52
|
db = await Database().init()
|
41
53
|
|
@@ -72,6 +84,16 @@ async def _main():
|
|
72
84
|
assert user is not None
|
73
85
|
print('User updated, credential:', user.credential)
|
74
86
|
|
87
|
+
if args.subparser_name == 'set-peer':
|
88
|
+
async with get_uconn() as uconn:
|
89
|
+
src_user = await uconn.get_user(args.src_username)
|
90
|
+
dst_user = await uconn.get_user(args.dst_username)
|
91
|
+
if src_user is None or dst_user is None:
|
92
|
+
print('User not found')
|
93
|
+
exit(1)
|
94
|
+
await uconn.set_peer_level(src_user.id, dst_user.id, args.level)
|
95
|
+
print(f"Peer set: [{src_user.username}] now have [{args.level.name}] access to [{dst_user.username}]")
|
96
|
+
|
75
97
|
if args.subparser_name == 'list':
|
76
98
|
async with get_uconn() as uconn:
|
77
99
|
term_width = os.get_terminal_size().columns
|
@@ -86,6 +108,11 @@ async def _main():
|
|
86
108
|
user_size_used = await fconn.user_size(user.id)
|
87
109
|
print('- Credential: ', user.credential)
|
88
110
|
print(f'- Storage: {fmt_storage_size(user_size_used)} / {fmt_storage_size(user.max_storage)}')
|
111
|
+
for p in AccessLevel:
|
112
|
+
if p > AccessLevel.NONE:
|
113
|
+
usernames = [x.username for x in await uconn.list_peer_users(user.id, p)]
|
114
|
+
if usernames:
|
115
|
+
print(f'- Peers [{p.name}]: {", ".join(usernames)}')
|
89
116
|
|
90
117
|
def main():
|
91
118
|
asyncio.run(_main())
|
@@ -27,6 +27,15 @@ CREATE TABLE IF NOT EXISTS usize (
|
|
27
27
|
size INTEGER DEFAULT 0
|
28
28
|
);
|
29
29
|
|
30
|
+
CREATE TABLE IF NOT EXISTS upeer (
|
31
|
+
src_user_id INTEGER NOT NULL,
|
32
|
+
dst_user_id INTEGER NOT NULL,
|
33
|
+
access_level INTEGER DEFAULT 0,
|
34
|
+
PRIMARY KEY(src_user_id, dst_user_id),
|
35
|
+
FOREIGN KEY(src_user_id) REFERENCES user(id),
|
36
|
+
FOREIGN KEY(dst_user_id) REFERENCES user(id)
|
37
|
+
);
|
38
|
+
|
30
39
|
CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url);
|
31
40
|
|
32
41
|
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
|
@@ -8,6 +8,7 @@ from functools import wraps
|
|
8
8
|
from typing import Callable, Awaitable
|
9
9
|
|
10
10
|
from .log import get_logger
|
11
|
+
from .error import DatabaseLockedError
|
11
12
|
from .config import DATA_HOME
|
12
13
|
|
13
14
|
async def execute_sql(conn: aiosqlite.Connection | aiosqlite.Cursor, name: str):
|
@@ -28,7 +29,7 @@ async def get_connection(read_only: bool = False) -> aiosqlite.Connection:
|
|
28
29
|
|
29
30
|
conn = await aiosqlite.connect(
|
30
31
|
get_db_uri(DATA_HOME / 'index.db', read_only=read_only),
|
31
|
-
timeout =
|
32
|
+
timeout = 20, uri = True
|
32
33
|
)
|
33
34
|
async with conn.cursor() as c:
|
34
35
|
await c.execute(
|
@@ -46,7 +47,7 @@ class SqlConnection:
|
|
46
47
|
|
47
48
|
class SqlConnectionPool:
|
48
49
|
_r_sem: Semaphore
|
49
|
-
_w_sem: Lock
|
50
|
+
_w_sem: Lock
|
50
51
|
def __init__(self):
|
51
52
|
self._readers: list[SqlConnection] = []
|
52
53
|
self._writer: None | SqlConnection = None
|
@@ -65,6 +66,17 @@ class SqlConnectionPool:
|
|
65
66
|
self._readers.append(SqlConnection(conn))
|
66
67
|
self._r_sem = Semaphore(n_read)
|
67
68
|
|
69
|
+
def status(self): # debug
|
70
|
+
assert self._writer
|
71
|
+
assert len(self._readers) == self.n_read
|
72
|
+
n_free_readers = sum([1 for c in self._readers if c.is_available])
|
73
|
+
n_free_writers = 1 if self._writer.is_available else 0
|
74
|
+
n_free_r_sem = self._r_sem._value
|
75
|
+
n_free_w_sem = 1 - self._w_sem.locked()
|
76
|
+
assert n_free_readers == n_free_r_sem, f"{n_free_readers} != {n_free_r_sem}"
|
77
|
+
assert n_free_writers == n_free_w_sem, f"{n_free_writers} != {n_free_w_sem}"
|
78
|
+
return f"Readers: {n_free_readers}/{self.n_read}, Writers: {n_free_writers}/{1}"
|
79
|
+
|
68
80
|
@property
|
69
81
|
def n_read(self):
|
70
82
|
return len(self._readers)
|
@@ -142,6 +154,10 @@ async def unique_cursor(is_write: bool = False):
|
|
142
154
|
connection_obj = await g_pool.get()
|
143
155
|
try:
|
144
156
|
yield await connection_obj.conn.cursor()
|
157
|
+
except Exception as e:
|
158
|
+
if 'database is locked' in str(e):
|
159
|
+
raise DatabaseLockedError from e
|
160
|
+
raise e
|
145
161
|
finally:
|
146
162
|
await g_pool.release(connection_obj)
|
147
163
|
else:
|
@@ -149,10 +165,13 @@ async def unique_cursor(is_write: bool = False):
|
|
149
165
|
connection_obj = await g_pool.get(w=True)
|
150
166
|
try:
|
151
167
|
yield await connection_obj.conn.cursor()
|
168
|
+
except Exception as e:
|
169
|
+
if 'database is locked' in str(e):
|
170
|
+
raise DatabaseLockedError from e
|
171
|
+
raise e
|
152
172
|
finally:
|
153
173
|
await g_pool.release(connection_obj)
|
154
174
|
|
155
|
-
# todo: add exclusive transaction option
|
156
175
|
@asynccontextmanager
|
157
176
|
async def transaction():
|
158
177
|
async with unique_cursor(is_write=True) as cur:
|