lfss 0.8.1__tar.gz → 0.8.3__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.1 → lfss-0.8.3}/PKG-INFO +2 -3
- {lfss-0.8.1 → lfss-0.8.3}/frontend/info.js +8 -5
- {lfss-0.8.1 → lfss-0.8.3}/frontend/scripts.js +1 -1
- {lfss-0.8.1 → lfss-0.8.3}/frontend/thumb.js +4 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/api/__init__.py +16 -2
- {lfss-0.8.1 → lfss-0.8.3}/lfss/api/connector.py +13 -1
- lfss-0.8.3/lfss/cli/__init__.py +27 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/cli/cli.py +77 -8
- {lfss-0.8.1 → lfss-0.8.3}/lfss/cli/user.py +13 -4
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/database.py +81 -77
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/datatype.py +5 -6
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/server.py +111 -36
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/utils.py +20 -1
- {lfss-0.8.1 → lfss-0.8.3}/pyproject.toml +2 -2
- {lfss-0.8.1 → lfss-0.8.3}/Readme.md +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/docs/Known_issues.md +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/docs/Permission.md +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/api.js +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/index.html +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/info.css +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/login.css +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/login.js +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/popup.css +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/popup.js +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/state.js +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/styles.css +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/thumb.css +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/frontend/utils.js +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/cli/balance.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/cli/panel.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/cli/serve.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/cli/vacuum.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/sql/init.sql +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/sql/pragma.sql +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/__init__.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/bounded_pool.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/config.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/connection_pool.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/error.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/log.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/stat.py +0 -0
- {lfss-0.8.1 → lfss-0.8.3}/lfss/src/thumb.py +0 -0
@@ -1,13 +1,12 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.8.
|
3
|
+
Version: 0.8.3
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li, mengxun
|
7
7
|
Author-email: limengxun45@outlook.com
|
8
|
-
Requires-Python: >=3.
|
8
|
+
Requires-Python: >=3.10
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
10
|
-
Classifier: Programming Language :: Python :: 3.9
|
11
10
|
Classifier: Programming Language :: Python :: 3.10
|
12
11
|
Classifier: Programming Language :: Python :: 3.11
|
13
12
|
Classifier: Programming Language :: Python :: 3.12
|
@@ -12,8 +12,10 @@ const ensureSlashEnd = (path) => {
|
|
12
12
|
/**
|
13
13
|
* @param {FileRecord} r
|
14
14
|
* @param {UserRecord} u
|
15
|
+
* @param {Connector} c
|
15
16
|
*/
|
16
|
-
export function showInfoPanel(r, u){
|
17
|
+
export function showInfoPanel(r, u, c){
|
18
|
+
const origin = c.config.endpoint;
|
17
19
|
const innerHTML = `
|
18
20
|
<div class="info-container">
|
19
21
|
<div class="info-container-left">
|
@@ -46,7 +48,7 @@ export function showInfoPanel(r, u){
|
|
46
48
|
</div>
|
47
49
|
<div class="info-container-right">
|
48
50
|
<div class="info-path-copy">
|
49
|
-
<input type="text" value="${
|
51
|
+
<input type="text" value="${origin}/${r.url}" readonly>
|
50
52
|
<button class="copy-button" id='copy-btn-full-path'>📋</button>
|
51
53
|
</div>
|
52
54
|
<div class="info-path-copy">
|
@@ -58,7 +60,7 @@ export function showInfoPanel(r, u){
|
|
58
60
|
`
|
59
61
|
const [win, closeWin] = createFloatingWindow(innerHTML, {title: 'File Info'});
|
60
62
|
document.getElementById('copy-btn-full-path').onclick = () => {
|
61
|
-
copyToClipboard(
|
63
|
+
copyToClipboard(origin + '/' + r.url);
|
62
64
|
showPopup('Path copied to clipboard', {timeout: 2000, level: 'success'});
|
63
65
|
}
|
64
66
|
document.getElementById('copy-btn-rel-path').onclick = () => {
|
@@ -77,6 +79,7 @@ export function showDirInfoPanel(r, u, c){
|
|
77
79
|
if (fmtPath.endsWith('/')) {
|
78
80
|
fmtPath = fmtPath.slice(0, -1);
|
79
81
|
}
|
82
|
+
const origin = c.config.endpoint;
|
80
83
|
const innerHTML = `
|
81
84
|
<div class="info-container">
|
82
85
|
<div class="info-container-left">
|
@@ -105,7 +108,7 @@ export function showDirInfoPanel(r, u, c){
|
|
105
108
|
</div>
|
106
109
|
<div class="info-container-right">
|
107
110
|
<div class="info-path-copy">
|
108
|
-
<input type="text" value="${
|
111
|
+
<input type="text" value="${origin}/${ensureSlashEnd(r.url)}" readonly>
|
109
112
|
<button class="copy-button" id='copy-btn-full-path'>📋</button>
|
110
113
|
</div>
|
111
114
|
<div class="info-path-copy">
|
@@ -117,7 +120,7 @@ export function showDirInfoPanel(r, u, c){
|
|
117
120
|
`
|
118
121
|
const [win, closeWin] = createFloatingWindow(innerHTML, {title: 'File Info'});
|
119
122
|
document.getElementById('copy-btn-full-path').onclick = () => {
|
120
|
-
copyToClipboard(
|
123
|
+
copyToClipboard(origin + '/' + ensureSlashEnd(r.url));
|
121
124
|
showPopup('Path copied to clipboard', {timeout: 2000, level: 'success'});
|
122
125
|
}
|
123
126
|
document.getElementById('copy-btn-rel-path').onclick = () => {
|
@@ -459,7 +459,7 @@ async function refreshFileList(){
|
|
459
459
|
infoButton.style.cursor = 'pointer';
|
460
460
|
infoButton.textContent = 'Details';
|
461
461
|
infoButton.addEventListener('click', () => {
|
462
|
-
showInfoPanel(file, userRecord);
|
462
|
+
showInfoPanel(file, userRecord, conn);
|
463
463
|
});
|
464
464
|
actContainer.appendChild(infoButton);
|
465
465
|
|
@@ -13,6 +13,7 @@ const ICON_ZIP = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><t
|
|
13
13
|
const ICON_CODE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>file-code-outline</title><path d="M14 2H6C4.89 2 4 2.9 4 4V20C4 21.11 4.89 22 6 22H18C19.11 22 20 21.11 20 20V8L14 2M18 20H6V4H13V9H18V20M9.54 15.65L11.63 17.74L10.35 19L7 15.65L10.35 12.3L11.63 13.56L9.54 15.65M17 15.65L13.65 19L12.38 17.74L14.47 15.65L12.38 13.56L13.65 12.3L17 15.65Z" /></svg>'
|
14
14
|
const ICON_VIDEO = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>television-play</title><path d="M21,3H3C1.89,3 1,3.89 1,5V17A2,2 0 0,0 3,19H8V21H16V19H21A2,2 0 0,0 23,17V5C23,3.89 22.1,3 21,3M21,17H3V5H21M16,11L9,15V7" /></svg>'
|
15
15
|
const ICON_IMAGE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>image-outline</title><path d="M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z" /></svg>'
|
16
|
+
const ICON_MUSIC = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>music-box-outline</title><path d="M16,9H13V14.5A2.5,2.5 0 0,1 10.5,17A2.5,2.5 0 0,1 8,14.5A2.5,2.5 0 0,1 10.5,12C11.07,12 11.58,12.19 12,12.5V7H16V9M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3H19M5,5V19H19V5H5Z" /></svg>'
|
16
17
|
|
17
18
|
function getIconSVGFromMimeType(mimeType){
|
18
19
|
if (mimeType == 'directory'){
|
@@ -24,6 +25,9 @@ function getIconSVGFromMimeType(mimeType){
|
|
24
25
|
if (mimeType.startsWith('image/')){
|
25
26
|
return ICON_IMAGE;
|
26
27
|
}
|
28
|
+
if (mimeType.startsWith('audio/')){
|
29
|
+
return ICON_MUSIC;
|
30
|
+
}
|
27
31
|
|
28
32
|
if (['application/pdf', 'application/x-pdf'].includes(mimeType)){
|
29
33
|
return ICON_PDF;
|
@@ -16,6 +16,11 @@ def upload_file(
|
|
16
16
|
) -> tuple[bool, str]:
|
17
17
|
this_try = 0
|
18
18
|
error_msg = ""
|
19
|
+
assert not file_path.endswith('/'), "File path must not end with a slash."
|
20
|
+
if dst_url.endswith('/'):
|
21
|
+
fname = file_path.split('/')[-1]
|
22
|
+
dst_url = f"{dst_url}{fname}"
|
23
|
+
|
19
24
|
while this_try <= n_retries:
|
20
25
|
try:
|
21
26
|
fsize = os.path.getsize(file_path)
|
@@ -31,7 +36,9 @@ def upload_file(
|
|
31
36
|
raise e
|
32
37
|
if verbose:
|
33
38
|
print(f"Error uploading {file_path}: {e}, retrying...")
|
34
|
-
|
39
|
+
error_msg = str(e)
|
40
|
+
if hasattr(e, 'response'):
|
41
|
+
error_msg = f"{error_msg}, {e.response.text}" # type: ignore
|
35
42
|
this_try += 1
|
36
43
|
finally:
|
37
44
|
time.sleep(interval)
|
@@ -95,7 +102,12 @@ def download_file(
|
|
95
102
|
) -> tuple[bool, str]:
|
96
103
|
this_try = 0
|
97
104
|
error_msg = ""
|
105
|
+
assert not src_url.endswith('/'), "Source URL must not end with a slash."
|
98
106
|
while this_try <= n_retries:
|
107
|
+
if os.path.isdir(file_path):
|
108
|
+
fname = src_url.split('/')[-1]
|
109
|
+
file_path = os.path.join(file_path, fname)
|
110
|
+
|
99
111
|
if not overwrite and os.path.exists(file_path):
|
100
112
|
if verbose:
|
101
113
|
print(f"File {file_path} already exists, skipping download.")
|
@@ -124,7 +136,9 @@ def download_file(
|
|
124
136
|
raise e
|
125
137
|
if verbose:
|
126
138
|
print(f"Error downloading {src_url}: {e}, retrying...")
|
127
|
-
|
139
|
+
error_msg = str(e)
|
140
|
+
if hasattr(e, 'response'):
|
141
|
+
error_msg = f"{error_msg}, {e.response.text}" # type: ignore
|
128
142
|
this_try += 1
|
129
143
|
finally:
|
130
144
|
time.sleep(interval)
|
@@ -54,7 +54,7 @@ class Connector:
|
|
54
54
|
|
55
55
|
def _fetch_factory(
|
56
56
|
self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
|
57
|
-
path: str, search_params: dict = {}
|
57
|
+
path: str, search_params: dict = {}, extra_headers: dict = {}
|
58
58
|
):
|
59
59
|
if path.startswith('/'):
|
60
60
|
path = path[1:]
|
@@ -65,6 +65,7 @@ class Connector:
|
|
65
65
|
headers.update({
|
66
66
|
'Authorization': f"Bearer {self.config['token']}",
|
67
67
|
})
|
68
|
+
headers.update(extra_headers)
|
68
69
|
if self._session is not None:
|
69
70
|
response = self._session.request(method, url, headers=headers, **kwargs)
|
70
71
|
response.raise_for_status()
|
@@ -168,6 +169,17 @@ class Connector:
|
|
168
169
|
response = self._get(path)
|
169
170
|
if response is None: return None
|
170
171
|
return response.content
|
172
|
+
|
173
|
+
def get_partial(self, path: str, range_start: int = -1, range_end: int = -1) -> Optional[bytes]:
|
174
|
+
"""
|
175
|
+
Downloads a partial file from the specified path.
|
176
|
+
start and end are the byte offsets, both inclusive.
|
177
|
+
"""
|
178
|
+
response = self._fetch_factory('GET', path, extra_headers={
|
179
|
+
'Range': f"bytes={range_start if range_start >= 0 else ''}-{range_end if range_end >= 0 else ''}"
|
180
|
+
})()
|
181
|
+
if response is None: return None
|
182
|
+
return response.content
|
171
183
|
|
172
184
|
def get_stream(self, path: str) -> Iterator[bytes]:
|
173
185
|
"""Downloads a file from the specified path, will raise PathNotFoundError if path not found."""
|
@@ -0,0 +1,27 @@
|
|
1
|
+
from contextlib import contextmanager
|
2
|
+
from typing import Iterable, TypeVar, Generator
|
3
|
+
import requests, os
|
4
|
+
|
5
|
+
@contextmanager
|
6
|
+
def catch_request_error():
|
7
|
+
try:
|
8
|
+
yield
|
9
|
+
except requests.RequestException as e:
|
10
|
+
print(f"\033[31m[Request error]: {e}\033[0m")
|
11
|
+
if e.response is not None:
|
12
|
+
print(f"\033[91m[Error message]: {e.response.text}\033[0m")
|
13
|
+
|
14
|
+
T = TypeVar('T')
|
15
|
+
def line_sep(iter: Iterable[T], enable=True, start=True, end=True, color="\033[90m") -> Generator[T, None, None]:
|
16
|
+
screen_width = os.get_terminal_size().columns
|
17
|
+
def print_ln():
|
18
|
+
print(color + "-" * screen_width + "\033[0m")
|
19
|
+
|
20
|
+
if start and enable:
|
21
|
+
print_ln()
|
22
|
+
for i, line in enumerate(iter):
|
23
|
+
if enable and i > 0:
|
24
|
+
print_ln()
|
25
|
+
yield line
|
26
|
+
if end and enable:
|
27
|
+
print_ln()
|
@@ -1,7 +1,9 @@
|
|
1
1
|
from lfss.api import Connector, upload_directory, upload_file, download_file, download_directory
|
2
2
|
from pathlib import Path
|
3
|
-
import argparse
|
4
|
-
from lfss.src.datatype import FileReadPermission
|
3
|
+
import argparse, typing
|
4
|
+
from lfss.src.datatype import FileReadPermission, FileSortKey, DirSortKey
|
5
|
+
from lfss.src.utils import decode_uri_compnents
|
6
|
+
from . import catch_request_error, line_sep
|
5
7
|
|
6
8
|
def parse_permission(s: str) -> FileReadPermission:
|
7
9
|
if s.lower() == "public":
|
@@ -39,6 +41,29 @@ def parse_arguments():
|
|
39
41
|
sp_download.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
|
40
42
|
sp_download.add_argument("--retries", type=int, default=0, help="Number of retries")
|
41
43
|
|
44
|
+
# query
|
45
|
+
sp_query = sp.add_parser("query", help="Query files or directories metadata from the server")
|
46
|
+
sp_query.add_argument("path", help="Path to query", nargs="*", type=str)
|
47
|
+
|
48
|
+
# list directories
|
49
|
+
sp_list_d = sp.add_parser("list-dirs", help="List directories of a given path")
|
50
|
+
sp_list_d.add_argument("path", help="Path to list", type=str)
|
51
|
+
sp_list_d.add_argument("--offset", type=int, default=0, help="Offset of the list")
|
52
|
+
sp_list_d.add_argument("--limit", type=int, default=100, help="Limit of the list")
|
53
|
+
sp_list_d.add_argument("-l", "--long", action="store_true", help="Detailed list, including all metadata")
|
54
|
+
sp_list_d.add_argument("--order", "--order-by", type=str, help="Order of the list", default="", choices=typing.get_args(DirSortKey))
|
55
|
+
sp_list_d.add_argument("--reverse", "--order-desc", action="store_true", help="Reverse the list order")
|
56
|
+
|
57
|
+
# list files
|
58
|
+
sp_list_f = sp.add_parser("list-files", help="List files of a given path")
|
59
|
+
sp_list_f.add_argument("path", help="Path to list", type=str)
|
60
|
+
sp_list_f.add_argument("--offset", type=int, default=0, help="Offset of the list")
|
61
|
+
sp_list_f.add_argument("--limit", type=int, default=100, help="Limit of the list")
|
62
|
+
sp_list_f.add_argument("-r", "--recursive", "--flat", action="store_true", help="List files recursively")
|
63
|
+
sp_list_f.add_argument("-l", "--long", action="store_true", help="Detailed list, including all metadata")
|
64
|
+
sp_list_f.add_argument("--order", "--order-by", type=str, help="Order of the list", default="", choices=typing.get_args(FileSortKey))
|
65
|
+
sp_list_f.add_argument("--reverse", "--order-desc", action="store_true", help="Reverse the list order")
|
66
|
+
|
42
67
|
return parser.parse_args()
|
43
68
|
|
44
69
|
def main():
|
@@ -57,11 +82,11 @@ def main():
|
|
57
82
|
permission=args.permission
|
58
83
|
)
|
59
84
|
if failed_upload:
|
60
|
-
print("
|
85
|
+
print("\033[91mFailed to upload:\033[0m")
|
61
86
|
for path in failed_upload:
|
62
87
|
print(f" {path}")
|
63
88
|
else:
|
64
|
-
success = upload_file(
|
89
|
+
success, msg = upload_file(
|
65
90
|
connector,
|
66
91
|
file_path = args.src,
|
67
92
|
dst_url = args.dst,
|
@@ -72,7 +97,7 @@ def main():
|
|
72
97
|
permission=args.permission
|
73
98
|
)
|
74
99
|
if not success:
|
75
|
-
print("
|
100
|
+
print("\033[91mFailed to upload: \033[0m", msg)
|
76
101
|
|
77
102
|
elif args.command == "download":
|
78
103
|
is_dir = args.src.endswith("/")
|
@@ -86,11 +111,11 @@ def main():
|
|
86
111
|
overwrite=args.overwrite
|
87
112
|
)
|
88
113
|
if failed_download:
|
89
|
-
print("
|
114
|
+
print("\033[91mFailed to download:\033[0m")
|
90
115
|
for path in failed_download:
|
91
116
|
print(f" {path}")
|
92
117
|
else:
|
93
|
-
success = download_file(
|
118
|
+
success, msg = download_file(
|
94
119
|
connector,
|
95
120
|
src_url = args.src,
|
96
121
|
file_path = args.dst,
|
@@ -100,7 +125,51 @@ def main():
|
|
100
125
|
overwrite=args.overwrite
|
101
126
|
)
|
102
127
|
if not success:
|
103
|
-
print("
|
128
|
+
print("\033[91mFailed to download: \033[0m", msg)
|
129
|
+
|
130
|
+
elif args.command == "query":
|
131
|
+
for path in args.path:
|
132
|
+
with catch_request_error():
|
133
|
+
res = connector.get_metadata(path)
|
134
|
+
if res is None:
|
135
|
+
print(f"\033[31mNot found\033[0m ({path})")
|
136
|
+
else:
|
137
|
+
print(res)
|
138
|
+
|
139
|
+
elif args.command == "list-files":
|
140
|
+
with catch_request_error():
|
141
|
+
res = connector.list_files(
|
142
|
+
args.path,
|
143
|
+
offset=args.offset,
|
144
|
+
limit=args.limit,
|
145
|
+
flat=args.recursive,
|
146
|
+
order_by=args.order,
|
147
|
+
order_desc=args.reverse,
|
148
|
+
)
|
149
|
+
for i, f in enumerate(line_sep(res)):
|
150
|
+
f.url = decode_uri_compnents(f.url)
|
151
|
+
print(f"[{i+1}] {f if args.long else f.url}")
|
152
|
+
|
153
|
+
if len(res) == args.limit:
|
154
|
+
print(f"\033[33m[Warning] List limit reached, use --offset and --limit to list more files.")
|
155
|
+
|
156
|
+
elif args.command == "list-dirs":
|
157
|
+
with catch_request_error():
|
158
|
+
res = connector.list_dirs(
|
159
|
+
args.path,
|
160
|
+
offset=args.offset,
|
161
|
+
limit=args.limit,
|
162
|
+
skim=not args.long,
|
163
|
+
order_by=args.order,
|
164
|
+
order_desc=args.reverse,
|
165
|
+
)
|
166
|
+
for i, d in enumerate(line_sep(res)):
|
167
|
+
d.url = decode_uri_compnents(d.url)
|
168
|
+
print(f"[{i+1}] {d if args.long else d.url}")
|
169
|
+
|
170
|
+
if len(res) == args.limit:
|
171
|
+
print(f"\033[33m[Warning] List limit reached, use --offset and --limit to list more directories.")
|
172
|
+
|
104
173
|
else:
|
105
174
|
raise NotImplementedError(f"Command {args.command} not implemented.")
|
106
175
|
|
@@ -1,8 +1,8 @@
|
|
1
|
-
import argparse, asyncio
|
1
|
+
import argparse, asyncio, os
|
2
2
|
from contextlib import asynccontextmanager
|
3
3
|
from .cli import parse_permission, FileReadPermission
|
4
|
-
from ..src.utils import parse_storage_size
|
5
|
-
from ..src.database import Database, FileReadPermission, transaction, UserConn
|
4
|
+
from ..src.utils import parse_storage_size, fmt_storage_size
|
5
|
+
from ..src.database import Database, FileReadPermission, transaction, UserConn, unique_cursor, FileConn
|
6
6
|
from ..src.connection_pool import global_entrance
|
7
7
|
|
8
8
|
@global_entrance(1)
|
@@ -33,6 +33,7 @@ async def _main():
|
|
33
33
|
sp_set.add_argument('--max-storage', type=parse_storage_size, default=None)
|
34
34
|
|
35
35
|
sp_list = sp.add_parser('list')
|
36
|
+
sp_list.add_argument("username", nargs='*', type=str, default=None)
|
36
37
|
sp_list.add_argument("-l", "--long", action="store_true")
|
37
38
|
|
38
39
|
args = parser.parse_args()
|
@@ -73,10 +74,18 @@ async def _main():
|
|
73
74
|
|
74
75
|
if args.subparser_name == 'list':
|
75
76
|
async with get_uconn() as uconn:
|
77
|
+
term_width = os.get_terminal_size().columns
|
76
78
|
async for user in uconn.all():
|
79
|
+
if args.username and not user.username in args.username:
|
80
|
+
continue
|
81
|
+
print("\033[90m-\033[0m" * term_width)
|
77
82
|
print(user)
|
78
83
|
if args.long:
|
79
|
-
|
84
|
+
async with unique_cursor() as c:
|
85
|
+
fconn = FileConn(c)
|
86
|
+
user_size_used = await fconn.user_size(user.id)
|
87
|
+
print('- Credential: ', user.credential)
|
88
|
+
print(f'- Storage: {fmt_storage_size(user_size_used)} / {fmt_storage_size(user.max_storage)}')
|
80
89
|
|
81
90
|
def main():
|
82
91
|
asyncio.run(_main())
|
@@ -21,6 +21,12 @@ from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debou
|
|
21
21
|
from .error import *
|
22
22
|
|
23
23
|
class DBObjectBase(ABC):
|
24
|
+
"""
|
25
|
+
NOTE:
|
26
|
+
The object of this class should hold a cursor to the database.
|
27
|
+
The methods calling the cursor should not be called concurrently.
|
28
|
+
"""
|
29
|
+
|
24
30
|
logger = get_logger('database', global_instance=True)
|
25
31
|
_cur: aiosqlite.Cursor
|
26
32
|
|
@@ -206,7 +212,7 @@ class FileConn(DBObjectBase):
|
|
206
212
|
return DirectoryRecord(dir_url)
|
207
213
|
else:
|
208
214
|
return await self.get_path_record(dir_url)
|
209
|
-
dirs = await
|
215
|
+
dirs = [await get_dir(url + d) for d in dirs_str]
|
210
216
|
return dirs
|
211
217
|
|
212
218
|
async def count_path_files(self, url: str, flat: bool = False):
|
@@ -308,18 +314,14 @@ class FileConn(DBObjectBase):
|
|
308
314
|
return res[0] or 0
|
309
315
|
|
310
316
|
async def update_file_record(
|
311
|
-
self, url,
|
317
|
+
self, url,
|
318
|
+
permission: Optional[FileReadPermission] = None,
|
319
|
+
mime_type: Optional[str] = None
|
312
320
|
):
|
313
|
-
|
314
|
-
|
315
|
-
if
|
316
|
-
|
317
|
-
if permission is None:
|
318
|
-
permission = old.permission
|
319
|
-
await self.cur.execute(
|
320
|
-
"UPDATE fmeta SET owner_id = ?, permission = ? WHERE url = ?",
|
321
|
-
(owner_id, int(permission), url)
|
322
|
-
)
|
321
|
+
if permission is not None:
|
322
|
+
await self.cur.execute("UPDATE fmeta SET permission = ? WHERE url = ?", (int(permission), url))
|
323
|
+
if mime_type is not None:
|
324
|
+
await self.cur.execute("UPDATE fmeta SET mime_type = ? WHERE url = ?", (mime_type, url))
|
323
325
|
self.logger.info(f"Updated file {url}")
|
324
326
|
|
325
327
|
async def set_file_record(
|
@@ -392,7 +394,7 @@ class FileConn(DBObjectBase):
|
|
392
394
|
self.logger.info(f"Deleted {len(ret)} file records for user {owner_id}") # type: ignore
|
393
395
|
return ret
|
394
396
|
|
395
|
-
async def delete_path_records(self, path: str,
|
397
|
+
async def delete_path_records(self, path: str, under_owner_id: Optional[int] = None) -> list[FileRecord]:
|
396
398
|
"""Delete all records with url starting with path"""
|
397
399
|
# update user size
|
398
400
|
cursor = await self.cur.execute("SELECT DISTINCT owner_id FROM fmeta WHERE url LIKE ?", (path + '%', ))
|
@@ -406,10 +408,10 @@ class FileConn(DBObjectBase):
|
|
406
408
|
# if any new records are created here, the size update may be inconsistent
|
407
409
|
# but it's not a big deal... we should have only one writer
|
408
410
|
|
409
|
-
if
|
411
|
+
if under_owner_id is None:
|
410
412
|
res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? RETURNING *", (path + '%', ))
|
411
413
|
else:
|
412
|
-
res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? AND owner_id = ? RETURNING *", (path + '%',
|
414
|
+
res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? AND owner_id = ? RETURNING *", (path + '%', under_owner_id))
|
413
415
|
all_f_rec = await res.fetchall()
|
414
416
|
self.logger.info(f"Deleted {len(all_f_rec)} file(s) for path {path}") # type: ignore
|
415
417
|
return [self.parse_record(r) for r in all_f_rec]
|
@@ -431,20 +433,41 @@ class FileConn(DBObjectBase):
|
|
431
433
|
raise
|
432
434
|
return size_sum
|
433
435
|
|
434
|
-
async def get_file_blob(self, file_id: str) ->
|
436
|
+
async def get_file_blob(self, file_id: str, start_byte = -1, end_byte = -1) -> bytes:
|
435
437
|
cursor = await self.cur.execute("SELECT data FROM blobs.fdata WHERE file_id = ?", (file_id, ))
|
436
438
|
res = await cursor.fetchone()
|
437
439
|
if res is None:
|
438
|
-
|
439
|
-
|
440
|
+
raise FileNotFoundError(f"File {file_id} not found")
|
441
|
+
blob = res[0]
|
442
|
+
match (start_byte, end_byte):
|
443
|
+
case (-1, -1):
|
444
|
+
return blob
|
445
|
+
case (s, -1):
|
446
|
+
return blob[s:]
|
447
|
+
case (-1, e):
|
448
|
+
return blob[:e]
|
449
|
+
case (s, e):
|
450
|
+
return blob[s:e]
|
440
451
|
|
441
|
-
|
452
|
+
@staticmethod
|
453
|
+
async def get_file_blob_external(file_id: str, start_byte = -1, end_byte = -1) -> AsyncIterable[bytes]:
|
442
454
|
assert (LARGE_BLOB_DIR / file_id).exists(), f"File {file_id} not found"
|
443
455
|
async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'rb') as f:
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
456
|
+
if start_byte >= 0:
|
457
|
+
await f.seek(start_byte)
|
458
|
+
if end_byte >= 0:
|
459
|
+
while True:
|
460
|
+
head_ptr = await f.tell()
|
461
|
+
if head_ptr >= end_byte:
|
462
|
+
break
|
463
|
+
chunk = await f.read(min(CHUNK_SIZE, end_byte - head_ptr))
|
464
|
+
if not chunk: break
|
465
|
+
yield chunk
|
466
|
+
else:
|
467
|
+
while True:
|
468
|
+
chunk = await f.read(CHUNK_SIZE)
|
469
|
+
if not chunk: break
|
470
|
+
yield chunk
|
448
471
|
|
449
472
|
@staticmethod
|
450
473
|
async def delete_file_blob_external(file_id: str):
|
@@ -521,15 +544,16 @@ class Database:
|
|
521
544
|
await execute_sql(conn, 'init.sql')
|
522
545
|
return self
|
523
546
|
|
524
|
-
async def update_file_record(self,
|
547
|
+
async def update_file_record(self, url: str, permission: FileReadPermission, op_user: Optional[UserRecord] = None):
|
525
548
|
validate_url(url)
|
526
549
|
async with transaction() as conn:
|
527
550
|
fconn = FileConn(conn)
|
528
551
|
r = await fconn.get_file_record(url)
|
529
552
|
if r is None:
|
530
553
|
raise PathNotFoundError(f"File {url} not found")
|
531
|
-
if
|
532
|
-
|
554
|
+
if op_user is not None:
|
555
|
+
if r.owner_id != op_user.id and not op_user.is_admin:
|
556
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot update file {url}")
|
533
557
|
await fconn.update_file_record(url, permission=permission)
|
534
558
|
|
535
559
|
async def save_file(
|
@@ -561,8 +585,7 @@ class Database:
|
|
561
585
|
|
562
586
|
# check mime type
|
563
587
|
if mime_type is None:
|
564
|
-
|
565
|
-
mime_type, _ = mimetypes.guess_type(fname)
|
588
|
+
mime_type, _ = mimetypes.guess_type(url)
|
566
589
|
if mime_type is None:
|
567
590
|
await f.seek(0)
|
568
591
|
mime_type = mimesniff.what(await f.read(1024))
|
@@ -591,45 +614,25 @@ class Database:
|
|
591
614
|
await FileConn(w_cur).set_file_record(
|
592
615
|
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
593
616
|
permission=permission, external=True, mime_type=mime_type)
|
594
|
-
|
595
|
-
await delayed_log_activity(user.username)
|
596
617
|
return file_size
|
597
618
|
|
598
|
-
async def
|
619
|
+
async def read_file(self, url: str, start_byte = -1, end_byte = -1) -> AsyncIterable[bytes]:
|
620
|
+
# end byte is exclusive: [start_byte, end_byte)
|
599
621
|
validate_url(url)
|
600
|
-
async with unique_cursor() as cur:
|
601
|
-
fconn = FileConn(cur)
|
602
|
-
r = await fconn.get_file_record(url)
|
603
|
-
if r is None:
|
604
|
-
raise FileNotFoundError(f"File {url} not found")
|
605
|
-
if not r.external:
|
606
|
-
raise ValueError(f"File {url} is not stored externally, should use read_file instead")
|
607
|
-
ret = fconn.get_file_blob_external(r.file_id)
|
608
|
-
|
609
|
-
await delayed_log_access(url)
|
610
|
-
return ret
|
611
|
-
|
612
|
-
|
613
|
-
async def read_file(self, url: str) -> bytes:
|
614
|
-
validate_url(url)
|
615
|
-
|
616
622
|
async with unique_cursor() as cur:
|
617
623
|
fconn = FileConn(cur)
|
618
624
|
r = await fconn.get_file_record(url)
|
619
625
|
if r is None:
|
620
626
|
raise FileNotFoundError(f"File {url} not found")
|
621
627
|
if r.external:
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
await delayed_log_access(url)
|
630
|
-
return blob
|
628
|
+
ret = fconn.get_file_blob_external(r.file_id, start_byte=start_byte, end_byte=end_byte)
|
629
|
+
else:
|
630
|
+
async def blob_stream():
|
631
|
+
yield await fconn.get_file_blob(r.file_id, start_byte=start_byte, end_byte=end_byte)
|
632
|
+
ret = blob_stream()
|
633
|
+
return ret
|
631
634
|
|
632
|
-
async def delete_file(self, url: str,
|
635
|
+
async def delete_file(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
|
633
636
|
validate_url(url)
|
634
637
|
|
635
638
|
async with transaction() as cur:
|
@@ -637,10 +640,10 @@ class Database:
|
|
637
640
|
r = await fconn.delete_file_record(url)
|
638
641
|
if r is None:
|
639
642
|
return None
|
640
|
-
if
|
641
|
-
if r.owner_id !=
|
643
|
+
if op_user is not None:
|
644
|
+
if r.owner_id != op_user.id and not op_user.is_admin:
|
642
645
|
# will rollback
|
643
|
-
raise PermissionDeniedError(f"Permission denied: {
|
646
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot delete file {url}")
|
644
647
|
f_id = r.file_id
|
645
648
|
if r.external:
|
646
649
|
await fconn.delete_file_blob_external(f_id)
|
@@ -648,7 +651,7 @@ class Database:
|
|
648
651
|
await fconn.delete_file_blob(f_id)
|
649
652
|
return r
|
650
653
|
|
651
|
-
async def move_file(self, old_url: str, new_url: str,
|
654
|
+
async def move_file(self, old_url: str, new_url: str, op_user: Optional[UserRecord] = None):
|
652
655
|
validate_url(old_url)
|
653
656
|
validate_url(new_url)
|
654
657
|
|
@@ -657,12 +660,16 @@ class Database:
|
|
657
660
|
r = await fconn.get_file_record(old_url)
|
658
661
|
if r is None:
|
659
662
|
raise FileNotFoundError(f"File {old_url} not found")
|
660
|
-
if
|
661
|
-
if r.owner_id !=
|
662
|
-
raise PermissionDeniedError(f"Permission denied: {
|
663
|
+
if op_user is not None:
|
664
|
+
if r.owner_id != op_user.id:
|
665
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
|
663
666
|
await fconn.move_file(old_url, new_url)
|
667
|
+
|
668
|
+
new_mime, _ = mimetypes.guess_type(new_url)
|
669
|
+
if not new_mime is None:
|
670
|
+
await fconn.update_file_record(new_url, mime_type=new_mime)
|
664
671
|
|
665
|
-
async def move_path(self,
|
672
|
+
async def move_path(self, old_url: str, new_url: str, op_user: UserRecord):
|
666
673
|
validate_url(old_url, is_file=False)
|
667
674
|
validate_url(new_url, is_file=False)
|
668
675
|
|
@@ -676,20 +683,20 @@ class Database:
|
|
676
683
|
|
677
684
|
async with transaction() as cur:
|
678
685
|
first_component = new_url.split('/')[0]
|
679
|
-
if not (first_component ==
|
680
|
-
raise PermissionDeniedError(f"Permission denied: path must start with {
|
681
|
-
elif
|
686
|
+
if not (first_component == op_user.username or op_user.is_admin):
|
687
|
+
raise PermissionDeniedError(f"Permission denied: path must start with {op_user.username}")
|
688
|
+
elif op_user.is_admin:
|
682
689
|
uconn = UserConn(cur)
|
683
690
|
_is_user = await uconn.get_user(first_component)
|
684
691
|
if not _is_user:
|
685
692
|
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
686
693
|
|
687
694
|
# check if old path is under user's directory (non-admin)
|
688
|
-
if not old_url.startswith(
|
689
|
-
raise PermissionDeniedError(f"Permission denied: {
|
695
|
+
if not old_url.startswith(op_user.username + '/') and not op_user.is_admin:
|
696
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move path {old_url}")
|
690
697
|
|
691
698
|
fconn = FileConn(cur)
|
692
|
-
await fconn.move_path(old_url, new_url, 'overwrite',
|
699
|
+
await fconn.move_path(old_url, new_url, 'overwrite', op_user.id)
|
693
700
|
|
694
701
|
async def __batch_delete_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
|
695
702
|
# https://github.com/langchain-ai/langchain/issues/10321
|
@@ -709,13 +716,13 @@ class Database:
|
|
709
716
|
await fconn.delete_file_blob_external(external_ids[i])
|
710
717
|
await asyncio.gather(del_internal(), del_external())
|
711
718
|
|
712
|
-
async def delete_path(self, url: str,
|
719
|
+
async def delete_path(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
|
713
720
|
validate_url(url, is_file=False)
|
714
|
-
|
721
|
+
from_owner_id = op_user.id if op_user is not None and not op_user.is_admin else None
|
715
722
|
|
716
723
|
async with transaction() as cur:
|
717
724
|
fconn = FileConn(cur)
|
718
|
-
records = await fconn.delete_path_records(url,
|
725
|
+
records = await fconn.delete_path_records(url, from_owner_id)
|
719
726
|
if not records:
|
720
727
|
return None
|
721
728
|
await self.__batch_delete_file_blobs(fconn, records)
|
@@ -759,9 +766,6 @@ class Database:
|
|
759
766
|
blob = fconn.get_file_blob_external(f_id)
|
760
767
|
else:
|
761
768
|
blob = await fconn.get_file_blob(f_id)
|
762
|
-
if blob is None:
|
763
|
-
self.logger.warning(f"Blob not found for {url}")
|
764
|
-
continue
|
765
769
|
yield r, blob
|
766
770
|
|
767
771
|
@concurrent_wrap()
|
@@ -1,6 +1,5 @@
|
|
1
1
|
from enum import IntEnum
|
2
|
-
|
3
|
-
import dataclasses
|
2
|
+
import dataclasses, typing
|
4
3
|
|
5
4
|
class FileReadPermission(IntEnum):
|
6
5
|
UNSET = 0 # not set
|
@@ -55,7 +54,7 @@ class PathContents:
|
|
55
54
|
dirs: list[DirectoryRecord] = dataclasses.field(default_factory=list)
|
56
55
|
files: list[FileRecord] = dataclasses.field(default_factory=list)
|
57
56
|
|
58
|
-
FileSortKey = Literal['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
|
59
|
-
isValidFileSortKey = lambda x: x in
|
60
|
-
DirSortKey = Literal['', 'dirname']
|
61
|
-
isValidDirSortKey = lambda x: x in
|
57
|
+
FileSortKey = typing.Literal['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
|
58
|
+
isValidFileSortKey = lambda x: x in typing.get_args(FileSortKey)
|
59
|
+
DirSortKey = typing.Literal['', 'dirname']
|
60
|
+
isValidDirSortKey = lambda x: x in typing.get_args(DirSortKey)
|
@@ -6,7 +6,6 @@ from fastapi.responses import StreamingResponse
|
|
6
6
|
from fastapi.exceptions import HTTPException
|
7
7
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
8
8
|
from fastapi.middleware.cors import CORSMiddleware
|
9
|
-
import mimesniff
|
10
9
|
|
11
10
|
import asyncio, json, time
|
12
11
|
from contextlib import asynccontextmanager
|
@@ -14,10 +13,11 @@ from contextlib import asynccontextmanager
|
|
14
13
|
from .error import *
|
15
14
|
from .log import get_logger
|
16
15
|
from .stat import RequestDB
|
17
|
-
from .config import MAX_BUNDLE_BYTES,
|
16
|
+
from .config import MAX_BUNDLE_BYTES, CHUNK_SIZE
|
18
17
|
from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
|
19
18
|
from .connection_pool import global_connection_init, global_connection_close, unique_cursor
|
20
|
-
from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn
|
19
|
+
from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn
|
20
|
+
from .database import delayed_log_activity, delayed_log_access
|
21
21
|
from .datatype import (
|
22
22
|
FileReadPermission, FileRecord, UserRecord, PathContents,
|
23
23
|
FileSortKey, DirSortKey
|
@@ -80,6 +80,10 @@ async def get_current_user(
|
|
80
80
|
|
81
81
|
if not user:
|
82
82
|
raise HTTPException(status_code=401, detail="Invalid token")
|
83
|
+
|
84
|
+
if not user.id == 0:
|
85
|
+
await delayed_log_activity(user.username)
|
86
|
+
|
83
87
|
return user
|
84
88
|
|
85
89
|
async def registered_user(user: UserRecord = Depends(get_current_user)):
|
@@ -137,7 +141,8 @@ router_fs = APIRouter(prefix="")
|
|
137
141
|
@skip_request_log
|
138
142
|
async def emit_thumbnail(
|
139
143
|
path: str, download: bool,
|
140
|
-
create_time: Optional[str] = None
|
144
|
+
create_time: Optional[str] = None,
|
145
|
+
is_head = False
|
141
146
|
):
|
142
147
|
if path.endswith("/"):
|
143
148
|
fname = path.split("/")[-2]
|
@@ -153,42 +158,69 @@ async def emit_thumbnail(
|
|
153
158
|
}
|
154
159
|
if create_time is not None:
|
155
160
|
headers["Last-Modified"] = format_last_modified(create_time)
|
161
|
+
if is_head: return Response(status_code=200, headers=headers)
|
156
162
|
return Response(
|
157
163
|
content=thumb_blob, media_type=mime_type, headers=headers
|
158
164
|
)
|
159
165
|
async def emit_file(
|
160
166
|
file_record: FileRecord,
|
161
167
|
media_type: Optional[str] = None,
|
162
|
-
disposition = "attachment"
|
168
|
+
disposition = "attachment",
|
169
|
+
is_head = False,
|
170
|
+
range_start = -1,
|
171
|
+
range_end = -1
|
163
172
|
):
|
173
|
+
if range_start < 0: assert range_start == -1
|
174
|
+
if range_end < 0: assert range_end == -1
|
175
|
+
|
164
176
|
if media_type is None:
|
165
177
|
media_type = file_record.mime_type
|
166
178
|
path = file_record.url
|
167
179
|
fname = path.split("/")[-1]
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
content=fblob, media_type=media_type, headers={
|
172
|
-
"Content-Disposition": f"{disposition}; filename={fname}",
|
173
|
-
"Content-Length": str(len(fblob)),
|
174
|
-
"Last-Modified": format_last_modified(file_record.create_time)
|
175
|
-
}
|
176
|
-
)
|
180
|
+
|
181
|
+
if range_start == -1:
|
182
|
+
arng_s = 0 # actual range start
|
177
183
|
else:
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
184
|
+
arng_s = range_start
|
185
|
+
if range_end == -1:
|
186
|
+
arng_e = file_record.file_size - 1
|
187
|
+
else:
|
188
|
+
arng_e = range_end
|
189
|
+
|
190
|
+
if arng_s >= file_record.file_size or arng_e >= file_record.file_size:
|
191
|
+
raise HTTPException(status_code=416, detail="Range not satisfiable")
|
192
|
+
if arng_s > arng_e:
|
193
|
+
raise HTTPException(status_code=416, detail="Invalid range")
|
185
194
|
|
186
|
-
|
187
|
-
|
188
|
-
|
195
|
+
headers = {
|
196
|
+
"Content-Disposition": f"{disposition}; filename={fname}",
|
197
|
+
"Content-Length": str(arng_e - arng_s + 1),
|
198
|
+
"Content-Range": f"bytes {arng_s}-{arng_e}/{file_record.file_size}",
|
199
|
+
"Last-Modified": format_last_modified(file_record.create_time),
|
200
|
+
"Accept-Ranges": "bytes",
|
201
|
+
}
|
202
|
+
|
203
|
+
if is_head: return Response(status_code=200 if (range_start == -1 and range_end == -1) else 206, headers=headers)
|
204
|
+
|
205
|
+
await delayed_log_access(path)
|
206
|
+
return StreamingResponse(
|
207
|
+
await db.read_file(
|
208
|
+
path,
|
209
|
+
start_byte=arng_s if range_start != -1 else -1,
|
210
|
+
end_byte=arng_e + 1 if range_end != -1 else -1
|
211
|
+
),
|
212
|
+
media_type=media_type,
|
213
|
+
headers=headers,
|
214
|
+
status_code=206 if range_start != -1 or range_end != -1 else 200
|
215
|
+
)
|
216
|
+
|
217
|
+
async def get_file_impl(
|
218
|
+
request: Request,
|
219
|
+
user: UserRecord,
|
189
220
|
path: str,
|
190
|
-
download: bool = False,
|
191
|
-
|
221
|
+
download: bool = False,
|
222
|
+
thumb: bool = False,
|
223
|
+
is_head = False,
|
192
224
|
):
|
193
225
|
path = ensure_uri_compnents(path)
|
194
226
|
|
@@ -230,13 +262,58 @@ async def get_file(
|
|
230
262
|
if not allow_access:
|
231
263
|
raise HTTPException(status_code=403, detail=reason)
|
232
264
|
|
265
|
+
req_range = request.headers.get("Range", None)
|
266
|
+
if not req_range is None:
|
267
|
+
# handle range request
|
268
|
+
if not req_range.startswith("bytes="):
|
269
|
+
raise HTTPException(status_code=400, detail="Invalid range request")
|
270
|
+
range_str = req_range[6:].strip()
|
271
|
+
if "," in range_str:
|
272
|
+
raise HTTPException(status_code=400, detail="Multiple ranges not supported")
|
273
|
+
if "-" not in range_str:
|
274
|
+
raise HTTPException(status_code=400, detail="Invalid range request")
|
275
|
+
range_start, range_end = map(lambda x: int(x) if x != "" else -1 , range_str.split("-"))
|
276
|
+
else:
|
277
|
+
range_start, range_end = -1, -1
|
278
|
+
|
233
279
|
if thumb:
|
234
|
-
|
280
|
+
if (range_start != -1 or range_end != -1): logger.warning("Range request for thumbnail")
|
281
|
+
return await emit_thumbnail(path, download, create_time=file_record.create_time, is_head=is_head)
|
235
282
|
else:
|
236
283
|
if download:
|
237
|
-
return await emit_file(file_record, 'application/octet-stream', "attachment")
|
284
|
+
return await emit_file(file_record, 'application/octet-stream', "attachment", is_head = is_head, range_start=range_start, range_end=range_end)
|
238
285
|
else:
|
239
|
-
return await emit_file(file_record, None, "inline")
|
286
|
+
return await emit_file(file_record, None, "inline", is_head = is_head, range_start=range_start, range_end=range_end)
|
287
|
+
|
288
|
+
@router_fs.get("/{path:path}")
|
289
|
+
@handle_exception
|
290
|
+
async def get_file(
|
291
|
+
request: Request,
|
292
|
+
path: str,
|
293
|
+
download: bool = False, thumb: bool = False,
|
294
|
+
user: UserRecord = Depends(get_current_user)
|
295
|
+
):
|
296
|
+
return await get_file_impl(
|
297
|
+
request = request,
|
298
|
+
user = user, path = path, download = download, thumb = thumb
|
299
|
+
)
|
300
|
+
|
301
|
+
@router_fs.head("/{path:path}")
|
302
|
+
@handle_exception
|
303
|
+
async def head_file(
|
304
|
+
request: Request,
|
305
|
+
path: str,
|
306
|
+
download: bool = False, thumb: bool = False,
|
307
|
+
user: UserRecord = Depends(get_current_user)
|
308
|
+
):
|
309
|
+
if path.startswith("_api/"):
|
310
|
+
raise HTTPException(status_code=405, detail="HEAD not supported for API")
|
311
|
+
if path.endswith("/"):
|
312
|
+
raise HTTPException(status_code=405, detail="HEAD not supported for directory")
|
313
|
+
return await get_file_impl(
|
314
|
+
request = request,
|
315
|
+
user = user, path = path, download = download, thumb = thumb, is_head = True
|
316
|
+
)
|
240
317
|
|
241
318
|
@router_fs.put("/{path:path}")
|
242
319
|
@handle_exception
|
@@ -360,11 +437,10 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
|
|
360
437
|
logger.info(f"DELETE {path}, user: {user.username}")
|
361
438
|
|
362
439
|
if path.endswith("/"):
|
363
|
-
res = await db.delete_path(path, user
|
440
|
+
res = await db.delete_path(path, user)
|
364
441
|
else:
|
365
|
-
res = await db.delete_file(path, user
|
442
|
+
res = await db.delete_file(path, user)
|
366
443
|
|
367
|
-
await delayed_log_activity(user.username)
|
368
444
|
if res:
|
369
445
|
return Response(status_code=200, content="Deleted")
|
370
446
|
else:
|
@@ -456,16 +532,15 @@ async def update_file_meta(
|
|
456
532
|
path = ensure_uri_compnents(path)
|
457
533
|
if path.startswith("/"):
|
458
534
|
path = path[1:]
|
459
|
-
await delayed_log_activity(user.username)
|
460
535
|
|
461
536
|
# file
|
462
537
|
if not path.endswith("/"):
|
463
538
|
if perm is not None:
|
464
539
|
logger.info(f"Update permission of {path} to {perm}")
|
465
540
|
await db.update_file_record(
|
466
|
-
user = user,
|
467
541
|
url = path,
|
468
|
-
permission = FileReadPermission(perm)
|
542
|
+
permission = FileReadPermission(perm),
|
543
|
+
op_user = user,
|
469
544
|
)
|
470
545
|
|
471
546
|
if new_path is not None:
|
@@ -480,7 +555,7 @@ async def update_file_meta(
|
|
480
555
|
new_path = ensure_uri_compnents(new_path)
|
481
556
|
logger.info(f"Update path of {path} to {new_path}")
|
482
557
|
# currently only move own file, with overwrite
|
483
|
-
await db.move_path(
|
558
|
+
await db.move_path(path, new_path, user)
|
484
559
|
|
485
560
|
return Response(status_code=200, content="OK")
|
486
561
|
|
@@ -109,6 +109,17 @@ def parse_storage_size(s: str) -> int:
|
|
109
109
|
case 'g': return int(s[:-1]) * 1024**3
|
110
110
|
case 't': return int(s[:-1]) * 1024**4
|
111
111
|
case _: raise ValueError(f"Invalid file size string: {s}")
|
112
|
+
def fmt_storage_size(size: int) -> str:
|
113
|
+
""" Format the file size to human-readable format """
|
114
|
+
if size < 1024:
|
115
|
+
return f"{size}B"
|
116
|
+
if size < 1024**2:
|
117
|
+
return f"{size/1024:.2f}K"
|
118
|
+
if size < 1024**3:
|
119
|
+
return f"{size/1024**2:.2f}M"
|
120
|
+
if size < 1024**4:
|
121
|
+
return f"{size/1024**3:.2f}G"
|
122
|
+
return f"{size/1024**4:.2f}T"
|
112
123
|
|
113
124
|
_FnReturnT = TypeVar('_FnReturnT')
|
114
125
|
_AsyncReturnT = Awaitable[_FnReturnT]
|
@@ -136,4 +147,12 @@ def concurrent_wrap(executor=None):
|
|
136
147
|
loop = asyncio.new_event_loop()
|
137
148
|
return loop.run_until_complete(func(*args, **kwargs))
|
138
149
|
return sync_fn
|
139
|
-
return _concurrent_wrap
|
150
|
+
return _concurrent_wrap
|
151
|
+
|
152
|
+
# https://stackoverflow.com/a/279586/6775765
|
153
|
+
def static_vars(**kwargs):
|
154
|
+
def decorate(func):
|
155
|
+
for k in kwargs:
|
156
|
+
setattr(func, k, kwargs[k])
|
157
|
+
return func
|
158
|
+
return decorate
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "lfss"
|
3
|
-
version = "0.8.
|
3
|
+
version = "0.8.3"
|
4
4
|
description = "Lightweight file storage service"
|
5
5
|
authors = ["li, mengxun <limengxun45@outlook.com>"]
|
6
6
|
readme = "Readme.md"
|
@@ -9,7 +9,7 @@ repository = "https://github.com/MenxLi/lfss"
|
|
9
9
|
include = ["Readme.md", "docs/*", "frontend/*", "lfss/sql/*"]
|
10
10
|
|
11
11
|
[tool.poetry.dependencies]
|
12
|
-
python = ">=3.
|
12
|
+
python = ">=3.10" # PEP-622
|
13
13
|
requests = "2.*"
|
14
14
|
aiosqlite = "0.*"
|
15
15
|
aiofiles = "23.*"
|
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
|