lfss 0.8.0__tar.gz → 0.8.2__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.0 → lfss-0.8.2}/PKG-INFO +2 -1
- {lfss-0.8.0 → lfss-0.8.2}/frontend/api.js +59 -2
- {lfss-0.8.0 → lfss-0.8.2}/frontend/scripts.js +5 -5
- {lfss-0.8.0 → lfss-0.8.2}/lfss/api/__init__.py +37 -9
- {lfss-0.8.0 → lfss-0.8.2}/lfss/api/connector.py +37 -0
- lfss-0.8.2/lfss/cli/__init__.py +27 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/cli/cli.py +77 -8
- {lfss-0.8.0 → lfss-0.8.2}/lfss/cli/user.py +13 -4
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/config.py +1 -1
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/database.py +90 -84
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/datatype.py +5 -6
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/server.py +93 -61
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/utils.py +20 -1
- {lfss-0.8.0 → lfss-0.8.2}/pyproject.toml +3 -2
- {lfss-0.8.0 → lfss-0.8.2}/Readme.md +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/docs/Known_issues.md +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/docs/Permission.md +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/index.html +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/info.css +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/info.js +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/login.css +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/login.js +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/popup.css +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/popup.js +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/state.js +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/styles.css +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/thumb.css +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/thumb.js +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/frontend/utils.js +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/cli/balance.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/cli/panel.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/cli/serve.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/cli/vacuum.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/sql/init.sql +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/sql/pragma.sql +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/__init__.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/bounded_pool.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/connection_pool.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/error.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/log.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/stat.py +0 -0
- {lfss-0.8.0 → lfss-0.8.2}/lfss/src/thumb.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.8.
|
3
|
+
Version: 0.8.2
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li, mengxun
|
@@ -16,6 +16,7 @@ Requires-Dist: aiosqlite (==0.*)
|
|
16
16
|
Requires-Dist: fastapi (==0.*)
|
17
17
|
Requires-Dist: mimesniff (==1.*)
|
18
18
|
Requires-Dist: pillow
|
19
|
+
Requires-Dist: python-multipart
|
19
20
|
Requires-Dist: requests (==2.*)
|
20
21
|
Requires-Dist: uvicorn (==0.*)
|
21
22
|
Project-URL: Repository, https://github.com/MenxLi/lfss
|
@@ -73,7 +73,8 @@ export default class Connector {
|
|
73
73
|
method: 'PUT',
|
74
74
|
headers: {
|
75
75
|
'Authorization': 'Bearer ' + this.config.token,
|
76
|
-
'Content-Type': 'application/octet-stream'
|
76
|
+
'Content-Type': 'application/octet-stream',
|
77
|
+
'Content-Length': fileBytes.byteLength
|
77
78
|
},
|
78
79
|
body: fileBytes
|
79
80
|
});
|
@@ -83,6 +84,38 @@ export default class Connector {
|
|
83
84
|
return (await res.json()).url;
|
84
85
|
}
|
85
86
|
|
87
|
+
/**
|
88
|
+
* @param {string} path - the path to the file (url)
|
89
|
+
* @param {File} file - the file to upload
|
90
|
+
* @returns {Promise<string>} - the promise of the request, the url of the file
|
91
|
+
*/
|
92
|
+
async post(path, file, {
|
93
|
+
conflict = 'abort',
|
94
|
+
permission = 0
|
95
|
+
} = {}){
|
96
|
+
if (path.startsWith('/')){ path = path.slice(1); }
|
97
|
+
const dst = new URL(this.config.endpoint + '/' + path);
|
98
|
+
dst.searchParams.append('conflict', conflict);
|
99
|
+
dst.searchParams.append('permission', permission);
|
100
|
+
// post as multipart form data
|
101
|
+
const formData = new FormData();
|
102
|
+
formData.append('file', file);
|
103
|
+
const res = await fetch(dst.toString(), {
|
104
|
+
method: 'POST',
|
105
|
+
// don't include the content type, let the browser handle it
|
106
|
+
// https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/
|
107
|
+
headers: {
|
108
|
+
'Authorization': 'Bearer ' + this.config.token,
|
109
|
+
},
|
110
|
+
body: formData
|
111
|
+
});
|
112
|
+
|
113
|
+
if (res.status != 200 && res.status != 201){
|
114
|
+
throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await res.json()}`);
|
115
|
+
}
|
116
|
+
return (await res.json()).url;
|
117
|
+
}
|
118
|
+
|
86
119
|
/**
|
87
120
|
* @param {string} path - the path to the file (url), should end with .json
|
88
121
|
* @param {Objec} data - the data to upload
|
@@ -422,4 +455,28 @@ export async function listPath(conn, path, {
|
|
422
455
|
dirs: dirCount,
|
423
456
|
files: fileCount
|
424
457
|
}];
|
425
|
-
};
|
458
|
+
};
|
459
|
+
|
460
|
+
/**
|
461
|
+
* a function to wrap the upload function into one
|
462
|
+
* it will return the url of the file
|
463
|
+
*
|
464
|
+
* @typedef {Object} UploadOptions
|
465
|
+
* @property {string} conflict - the conflict resolution strategy, can be 'abort', 'replace', 'rename'
|
466
|
+
* @property {number} permission - the permission of the file, can be 0, 1, 2, 3
|
467
|
+
*
|
468
|
+
* @param {Connector} conn - the connector to the API
|
469
|
+
* @param {string} path - the path to the file (url)
|
470
|
+
* @param {File} file - the file to upload
|
471
|
+
* @param {UploadOptions} options - the options for the request
|
472
|
+
* @returns {Promise<string>} - the promise of the request, the url of the file
|
473
|
+
*/
|
474
|
+
export async function uploadFile(conn, path, file, {
|
475
|
+
conflict = 'abort',
|
476
|
+
permission = 0
|
477
|
+
} = {}){
|
478
|
+
if (file.size < 1024 * 1024 * 10){
|
479
|
+
return await conn.put(path, file, {conflict, permission});
|
480
|
+
}
|
481
|
+
return await conn.post(path, file, {conflict, permission});
|
482
|
+
}
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import { permMap, listPath } from './api.js';
|
1
|
+
import { permMap, listPath, uploadFile } from './api.js';
|
2
2
|
import { showFloatingWindowLineInput, showPopup } from './popup.js';
|
3
3
|
import { formatSize, decodePathURI, ensurePathURI, getRandomString, cvtGMT2Local, debounce, encodePathURI, asHtmlText } from './utils.js';
|
4
4
|
import { showInfoPanel, showDirInfoPanel } from './info.js';
|
@@ -132,7 +132,7 @@ uploadButton.addEventListener('click', () => {
|
|
132
132
|
}
|
133
133
|
path = path + fileName;
|
134
134
|
showPopup('Uploading...', {level: 'info', timeout: 3000});
|
135
|
-
conn
|
135
|
+
uploadFile(conn, path, file, {'conflict': 'overwrite'})
|
136
136
|
.then(() => {
|
137
137
|
refreshFileList();
|
138
138
|
uploadFileNameInput.value = '';
|
@@ -178,10 +178,10 @@ Are you sure you want to proceed?
|
|
178
178
|
`)){ return; }
|
179
179
|
|
180
180
|
let counter = 0;
|
181
|
-
async function
|
181
|
+
async function uploadFileFn(...args){
|
182
182
|
const [file, path] = args;
|
183
183
|
try{
|
184
|
-
await conn
|
184
|
+
await uploadFile(conn, path, file, {conflict: 'overwrite'});
|
185
185
|
}
|
186
186
|
catch (err){
|
187
187
|
showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
|
@@ -194,7 +194,7 @@ Are you sure you want to proceed?
|
|
194
194
|
for (let i = 0; i < files.length; i++){
|
195
195
|
const file = files[i];
|
196
196
|
const path = dstPath + file.name;
|
197
|
-
promises.push(
|
197
|
+
promises.push(uploadFileFn(file, path));
|
198
198
|
}
|
199
199
|
showPopup('Uploading multiple files...', {level: 'info', timeout: 3000});
|
200
200
|
Promise.all(promises).then(
|
@@ -16,18 +16,29 @@ 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
|
-
|
22
|
-
|
23
|
-
|
26
|
+
fsize = os.path.getsize(file_path)
|
27
|
+
if fsize < 32 * 1024 * 1024: # 32MB
|
28
|
+
with open(file_path, 'rb') as f:
|
29
|
+
blob = f.read()
|
30
|
+
connector.put(dst_url, blob, **put_kwargs)
|
31
|
+
else:
|
32
|
+
connector.post(dst_url, file_path, **put_kwargs)
|
24
33
|
break
|
25
34
|
except Exception as e:
|
26
35
|
if isinstance(e, KeyboardInterrupt):
|
27
36
|
raise e
|
28
37
|
if verbose:
|
29
38
|
print(f"Error uploading {file_path}: {e}, retrying...")
|
30
|
-
|
39
|
+
error_msg = str(e)
|
40
|
+
if hasattr(e, 'response'):
|
41
|
+
error_msg = f"{error_msg}, {e.response.text}" # type: ignore
|
31
42
|
this_try += 1
|
32
43
|
finally:
|
33
44
|
time.sleep(interval)
|
@@ -91,26 +102,43 @@ def download_file(
|
|
91
102
|
) -> tuple[bool, str]:
|
92
103
|
this_try = 0
|
93
104
|
error_msg = ""
|
105
|
+
assert not src_url.endswith('/'), "Source URL must not end with a slash."
|
94
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
|
+
|
95
111
|
if not overwrite and os.path.exists(file_path):
|
96
112
|
if verbose:
|
97
113
|
print(f"File {file_path} already exists, skipping download.")
|
98
114
|
return True, error_msg
|
99
115
|
try:
|
100
|
-
|
101
|
-
if
|
116
|
+
fmeta = connector.get_metadata(src_url)
|
117
|
+
if fmeta is None:
|
102
118
|
error_msg = "File not found."
|
103
119
|
return False, error_msg
|
120
|
+
|
104
121
|
pathlib.Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
105
|
-
|
106
|
-
|
122
|
+
fsize = fmeta.file_size # type: ignore
|
123
|
+
if fsize < 32 * 1024 * 1024: # 32MB
|
124
|
+
blob = connector.get(src_url)
|
125
|
+
assert blob is not None
|
126
|
+
with open(file_path, 'wb') as f:
|
127
|
+
f.write(blob)
|
128
|
+
else:
|
129
|
+
with open(file_path, 'wb') as f:
|
130
|
+
for chunk in connector.get_stream(src_url):
|
131
|
+
f.write(chunk)
|
107
132
|
break
|
133
|
+
|
108
134
|
except Exception as e:
|
109
135
|
if isinstance(e, KeyboardInterrupt):
|
110
136
|
raise e
|
111
137
|
if verbose:
|
112
138
|
print(f"Error downloading {src_url}: {e}, retrying...")
|
113
|
-
|
139
|
+
error_msg = str(e)
|
140
|
+
if hasattr(e, 'response'):
|
141
|
+
error_msg = f"{error_msg}, {e.response.text}" # type: ignore
|
114
142
|
this_try += 1
|
115
143
|
finally:
|
116
144
|
time.sleep(interval)
|
@@ -4,6 +4,7 @@ import os
|
|
4
4
|
import requests
|
5
5
|
import requests.adapters
|
6
6
|
import urllib.parse
|
7
|
+
from tempfile import SpooledTemporaryFile
|
7
8
|
from lfss.src.error import PathNotFoundError
|
8
9
|
from lfss.src.datatype import (
|
9
10
|
FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents,
|
@@ -95,6 +96,42 @@ class Connector:
|
|
95
96
|
)
|
96
97
|
return response.json()
|
97
98
|
|
99
|
+
def post(self, path, file: str | bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
|
100
|
+
"""
|
101
|
+
Uploads a file to the specified path,
|
102
|
+
using the POST method, with form-data/multipart.
|
103
|
+
file can be a path to a file on disk, or bytes.
|
104
|
+
"""
|
105
|
+
|
106
|
+
# Skip ahead by checking if the file already exists
|
107
|
+
if conflict == 'skip-ahead':
|
108
|
+
exists = self.get_metadata(path)
|
109
|
+
if exists is None:
|
110
|
+
conflict = 'skip'
|
111
|
+
else:
|
112
|
+
return {'status': 'skipped', 'path': path}
|
113
|
+
|
114
|
+
if isinstance(file, str):
|
115
|
+
assert os.path.exists(file), "File does not exist on disk"
|
116
|
+
fsize = os.path.getsize(file)
|
117
|
+
|
118
|
+
with open(file, 'rb') if isinstance(file, str) else SpooledTemporaryFile(max_size=1024*1024*32) as fp:
|
119
|
+
|
120
|
+
if isinstance(file, bytes):
|
121
|
+
fsize = len(file)
|
122
|
+
fp.write(file)
|
123
|
+
fp.seek(0)
|
124
|
+
|
125
|
+
# https://stackoverflow.com/questions/12385179/
|
126
|
+
print(f"Uploading {fsize} bytes")
|
127
|
+
response = self._fetch_factory('POST', path, search_params={
|
128
|
+
'permission': int(permission),
|
129
|
+
'conflict': conflict
|
130
|
+
})(
|
131
|
+
files={'file': fp},
|
132
|
+
)
|
133
|
+
return response.json()
|
134
|
+
|
98
135
|
def put_json(self, path: str, data: dict, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
|
99
136
|
"""Uploads a JSON file to the specified path."""
|
100
137
|
assert path.endswith('.json'), "Path must end with .json"
|
@@ -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())
|
@@ -18,7 +18,7 @@ if __env_large_file is not None:
|
|
18
18
|
LARGE_FILE_BYTES = parse_storage_size(__env_large_file)
|
19
19
|
else:
|
20
20
|
LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
|
21
|
-
|
21
|
+
MAX_MEM_FILE_BYTES = 128 * 1024 * 1024 # 128MB
|
22
22
|
MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
|
23
23
|
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming (on large files)
|
24
24
|
|
@@ -8,18 +8,25 @@ import zipfile, io, asyncio
|
|
8
8
|
|
9
9
|
import aiosqlite, aiofiles
|
10
10
|
import aiofiles.os
|
11
|
+
import mimetypes, mimesniff
|
11
12
|
|
12
13
|
from .connection_pool import execute_sql, unique_cursor, transaction
|
13
14
|
from .datatype import (
|
14
15
|
UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents,
|
15
16
|
FileSortKey, DirSortKey, isValidFileSortKey, isValidDirSortKey
|
16
17
|
)
|
17
|
-
from .config import LARGE_BLOB_DIR, CHUNK_SIZE
|
18
|
+
from .config import LARGE_BLOB_DIR, CHUNK_SIZE, LARGE_FILE_BYTES, MAX_MEM_FILE_BYTES
|
18
19
|
from .log import get_logger
|
19
20
|
from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async
|
20
21
|
from .error import *
|
21
22
|
|
22
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
|
+
|
23
30
|
logger = get_logger('database', global_instance=True)
|
24
31
|
_cur: aiosqlite.Cursor
|
25
32
|
|
@@ -205,7 +212,7 @@ class FileConn(DBObjectBase):
|
|
205
212
|
return DirectoryRecord(dir_url)
|
206
213
|
else:
|
207
214
|
return await self.get_path_record(dir_url)
|
208
|
-
dirs = await
|
215
|
+
dirs = [await get_dir(url + d) for d in dirs_str]
|
209
216
|
return dirs
|
210
217
|
|
211
218
|
async def count_path_files(self, url: str, flat: bool = False):
|
@@ -285,8 +292,7 @@ class FileConn(DBObjectBase):
|
|
285
292
|
async def user_size(self, user_id: int) -> int:
|
286
293
|
cursor = await self.cur.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, ))
|
287
294
|
res = await cursor.fetchone()
|
288
|
-
if res is None:
|
289
|
-
return -1
|
295
|
+
if res is None: return 0
|
290
296
|
return res[0]
|
291
297
|
async def _user_size_inc(self, user_id: int, inc: int):
|
292
298
|
self.logger.debug(f"Increasing user {user_id} size by {inc}")
|
@@ -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]
|
@@ -521,72 +523,77 @@ class Database:
|
|
521
523
|
await execute_sql(conn, 'init.sql')
|
522
524
|
return self
|
523
525
|
|
524
|
-
async def update_file_record(self,
|
526
|
+
async def update_file_record(self, url: str, permission: FileReadPermission, op_user: Optional[UserRecord] = None):
|
525
527
|
validate_url(url)
|
526
528
|
async with transaction() as conn:
|
527
529
|
fconn = FileConn(conn)
|
528
530
|
r = await fconn.get_file_record(url)
|
529
531
|
if r is None:
|
530
532
|
raise PathNotFoundError(f"File {url} not found")
|
531
|
-
if
|
532
|
-
|
533
|
+
if op_user is not None:
|
534
|
+
if r.owner_id != op_user.id and not op_user.is_admin:
|
535
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot update file {url}")
|
533
536
|
await fconn.update_file_record(url, permission=permission)
|
534
537
|
|
535
538
|
async def save_file(
|
536
539
|
self, u: int | str, url: str,
|
537
|
-
|
540
|
+
blob_stream: AsyncIterable[bytes],
|
538
541
|
permission: FileReadPermission = FileReadPermission.UNSET,
|
539
|
-
mime_type: str =
|
540
|
-
):
|
542
|
+
mime_type: Optional[str] = None
|
543
|
+
) -> int:
|
541
544
|
"""
|
542
|
-
|
545
|
+
Save a file to the database.
|
546
|
+
Will check file size and user storage limit,
|
547
|
+
should check permission before calling this method.
|
543
548
|
"""
|
544
549
|
validate_url(url)
|
545
550
|
async with unique_cursor() as cur:
|
546
551
|
user = await get_user(cur, u)
|
547
|
-
|
548
|
-
return
|
549
|
-
|
550
|
-
# check if the user is the owner of the path, or is admin
|
551
|
-
if url.startswith('/'):
|
552
|
-
url = url[1:]
|
553
|
-
first_component = url.split('/')[0]
|
554
|
-
if first_component != user.username:
|
555
|
-
if not user.is_admin:
|
556
|
-
raise PermissionDeniedError(f"Permission denied: {user.username} cannot write to {url}")
|
557
|
-
else:
|
558
|
-
if await get_user(cur, first_component) is None:
|
559
|
-
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
552
|
+
assert user is not None, f"User {u} not found"
|
560
553
|
|
561
554
|
fconn_r = FileConn(cur)
|
562
555
|
user_size_used = await fconn_r.user_size(user.id)
|
563
556
|
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
async with transaction() as w_cur:
|
571
|
-
fconn_w = FileConn(w_cur)
|
572
|
-
await fconn_w.set_file_blob(f_id, blob)
|
573
|
-
await fconn_w.set_file_record(
|
574
|
-
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
575
|
-
permission=permission, external=False, mime_type=mime_type)
|
576
|
-
else:
|
577
|
-
assert isinstance(blob, AsyncIterable)
|
578
|
-
f_id = uuid.uuid4().hex
|
579
|
-
file_size = await FileConn.set_file_blob_external(f_id, blob)
|
557
|
+
f_id = uuid.uuid4().hex
|
558
|
+
async with aiofiles.tempfile.SpooledTemporaryFile(max_size=MAX_MEM_FILE_BYTES) as f:
|
559
|
+
async for chunk in blob_stream:
|
560
|
+
await f.write(chunk)
|
561
|
+
file_size = await f.tell()
|
580
562
|
if user_size_used + file_size > user.max_storage:
|
581
|
-
await FileConn.delete_file_blob_external(f_id)
|
582
563
|
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
583
564
|
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
565
|
+
# check mime type
|
566
|
+
if mime_type is None:
|
567
|
+
mime_type, _ = mimetypes.guess_type(url)
|
568
|
+
if mime_type is None:
|
569
|
+
await f.seek(0)
|
570
|
+
mime_type = mimesniff.what(await f.read(1024))
|
571
|
+
if mime_type is None:
|
572
|
+
mime_type = 'application/octet-stream'
|
573
|
+
await f.seek(0)
|
574
|
+
|
575
|
+
if file_size < LARGE_FILE_BYTES:
|
576
|
+
blob = await f.read()
|
577
|
+
async with transaction() as w_cur:
|
578
|
+
fconn_w = FileConn(w_cur)
|
579
|
+
await fconn_w.set_file_blob(f_id, blob)
|
580
|
+
await fconn_w.set_file_record(
|
581
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
582
|
+
permission=permission, external=False, mime_type=mime_type)
|
583
|
+
|
584
|
+
else:
|
585
|
+
async def blob_stream_tempfile():
|
586
|
+
nonlocal f
|
587
|
+
while True:
|
588
|
+
chunk = await f.read(CHUNK_SIZE)
|
589
|
+
if not chunk: break
|
590
|
+
yield chunk
|
591
|
+
await FileConn.set_file_blob_external(f_id, blob_stream_tempfile())
|
592
|
+
async with transaction() as w_cur:
|
593
|
+
await FileConn(w_cur).set_file_record(
|
594
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
595
|
+
permission=permission, external=True, mime_type=mime_type)
|
596
|
+
return file_size
|
590
597
|
|
591
598
|
async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
|
592
599
|
validate_url(url)
|
@@ -598,11 +605,8 @@ class Database:
|
|
598
605
|
if not r.external:
|
599
606
|
raise ValueError(f"File {url} is not stored externally, should use read_file instead")
|
600
607
|
ret = fconn.get_file_blob_external(r.file_id)
|
601
|
-
|
602
|
-
await delayed_log_access(url)
|
603
608
|
return ret
|
604
609
|
|
605
|
-
|
606
610
|
async def read_file(self, url: str) -> bytes:
|
607
611
|
validate_url(url)
|
608
612
|
|
@@ -618,11 +622,9 @@ class Database:
|
|
618
622
|
blob = await fconn.get_file_blob(f_id)
|
619
623
|
if blob is None:
|
620
624
|
raise FileNotFoundError(f"File {url} data not found")
|
621
|
-
|
622
|
-
await delayed_log_access(url)
|
623
625
|
return blob
|
624
626
|
|
625
|
-
async def delete_file(self, url: str,
|
627
|
+
async def delete_file(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
|
626
628
|
validate_url(url)
|
627
629
|
|
628
630
|
async with transaction() as cur:
|
@@ -630,10 +632,10 @@ class Database:
|
|
630
632
|
r = await fconn.delete_file_record(url)
|
631
633
|
if r is None:
|
632
634
|
return None
|
633
|
-
if
|
634
|
-
if r.owner_id !=
|
635
|
+
if op_user is not None:
|
636
|
+
if r.owner_id != op_user.id and not op_user.is_admin:
|
635
637
|
# will rollback
|
636
|
-
raise PermissionDeniedError(f"Permission denied: {
|
638
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot delete file {url}")
|
637
639
|
f_id = r.file_id
|
638
640
|
if r.external:
|
639
641
|
await fconn.delete_file_blob_external(f_id)
|
@@ -641,7 +643,7 @@ class Database:
|
|
641
643
|
await fconn.delete_file_blob(f_id)
|
642
644
|
return r
|
643
645
|
|
644
|
-
async def move_file(self, old_url: str, new_url: str,
|
646
|
+
async def move_file(self, old_url: str, new_url: str, op_user: Optional[UserRecord] = None):
|
645
647
|
validate_url(old_url)
|
646
648
|
validate_url(new_url)
|
647
649
|
|
@@ -650,12 +652,16 @@ class Database:
|
|
650
652
|
r = await fconn.get_file_record(old_url)
|
651
653
|
if r is None:
|
652
654
|
raise FileNotFoundError(f"File {old_url} not found")
|
653
|
-
if
|
654
|
-
if r.owner_id !=
|
655
|
-
raise PermissionDeniedError(f"Permission denied: {
|
655
|
+
if op_user is not None:
|
656
|
+
if r.owner_id != op_user.id:
|
657
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
|
656
658
|
await fconn.move_file(old_url, new_url)
|
659
|
+
|
660
|
+
new_mime, _ = mimetypes.guess_type(new_url)
|
661
|
+
if not new_mime is None:
|
662
|
+
await fconn.update_file_record(new_url, mime_type=new_mime)
|
657
663
|
|
658
|
-
async def move_path(self,
|
664
|
+
async def move_path(self, old_url: str, new_url: str, op_user: UserRecord):
|
659
665
|
validate_url(old_url, is_file=False)
|
660
666
|
validate_url(new_url, is_file=False)
|
661
667
|
|
@@ -669,20 +675,20 @@ class Database:
|
|
669
675
|
|
670
676
|
async with transaction() as cur:
|
671
677
|
first_component = new_url.split('/')[0]
|
672
|
-
if not (first_component ==
|
673
|
-
raise PermissionDeniedError(f"Permission denied: path must start with {
|
674
|
-
elif
|
678
|
+
if not (first_component == op_user.username or op_user.is_admin):
|
679
|
+
raise PermissionDeniedError(f"Permission denied: path must start with {op_user.username}")
|
680
|
+
elif op_user.is_admin:
|
675
681
|
uconn = UserConn(cur)
|
676
682
|
_is_user = await uconn.get_user(first_component)
|
677
683
|
if not _is_user:
|
678
684
|
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
679
685
|
|
680
686
|
# check if old path is under user's directory (non-admin)
|
681
|
-
if not old_url.startswith(
|
682
|
-
raise PermissionDeniedError(f"Permission denied: {
|
687
|
+
if not old_url.startswith(op_user.username + '/') and not op_user.is_admin:
|
688
|
+
raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move path {old_url}")
|
683
689
|
|
684
690
|
fconn = FileConn(cur)
|
685
|
-
await fconn.move_path(old_url, new_url, 'overwrite',
|
691
|
+
await fconn.move_path(old_url, new_url, 'overwrite', op_user.id)
|
686
692
|
|
687
693
|
async def __batch_delete_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
|
688
694
|
# https://github.com/langchain-ai/langchain/issues/10321
|
@@ -702,13 +708,13 @@ class Database:
|
|
702
708
|
await fconn.delete_file_blob_external(external_ids[i])
|
703
709
|
await asyncio.gather(del_internal(), del_external())
|
704
710
|
|
705
|
-
async def delete_path(self, url: str,
|
711
|
+
async def delete_path(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
|
706
712
|
validate_url(url, is_file=False)
|
707
|
-
|
713
|
+
from_owner_id = op_user.id if op_user is not None and not op_user.is_admin else None
|
708
714
|
|
709
715
|
async with transaction() as cur:
|
710
716
|
fconn = FileConn(cur)
|
711
|
-
records = await fconn.delete_path_records(url,
|
717
|
+
records = await fconn.delete_path_records(url, from_owner_id)
|
712
718
|
if not records:
|
713
719
|
return None
|
714
720
|
await self.__batch_delete_file_blobs(fconn, records)
|
@@ -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)
|
@@ -1,24 +1,23 @@
|
|
1
1
|
from typing import Optional, Literal
|
2
2
|
from functools import wraps
|
3
3
|
|
4
|
-
from fastapi import FastAPI, APIRouter, Depends, Request, Response
|
4
|
+
from fastapi import FastAPI, APIRouter, Depends, Request, Response, UploadFile
|
5
5
|
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
|
-
import mimetypes
|
13
11
|
from contextlib import asynccontextmanager
|
14
12
|
|
15
13
|
from .error import *
|
16
14
|
from .log import get_logger
|
17
15
|
from .stat import RequestDB
|
18
|
-
from .config import MAX_BUNDLE_BYTES,
|
16
|
+
from .config import MAX_BUNDLE_BYTES, CHUNK_SIZE
|
19
17
|
from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
|
20
18
|
from .connection_pool import global_connection_init, global_connection_close, unique_cursor
|
21
|
-
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
|
22
21
|
from .datatype import (
|
23
22
|
FileReadPermission, FileRecord, UserRecord, PathContents,
|
24
23
|
FileSortKey, DirSortKey
|
@@ -81,6 +80,10 @@ async def get_current_user(
|
|
81
80
|
|
82
81
|
if not user:
|
83
82
|
raise HTTPException(status_code=401, detail="Invalid token")
|
83
|
+
|
84
|
+
if not user.id == 0:
|
85
|
+
await delayed_log_activity(user.username)
|
86
|
+
|
84
87
|
return user
|
85
88
|
|
86
89
|
async def registered_user(user: UserRecord = Depends(get_current_user)):
|
@@ -166,6 +169,8 @@ async def emit_file(
|
|
166
169
|
media_type = file_record.mime_type
|
167
170
|
path = file_record.url
|
168
171
|
fname = path.split("/")[-1]
|
172
|
+
|
173
|
+
await delayed_log_access(path)
|
169
174
|
if not file_record.external:
|
170
175
|
fblob = await db.read_file(path)
|
171
176
|
return Response(
|
@@ -246,18 +251,21 @@ async def put_file(
|
|
246
251
|
path: str,
|
247
252
|
conflict: Literal["overwrite", "skip", "abort"] = "abort",
|
248
253
|
permission: int = 0,
|
249
|
-
user: UserRecord = Depends(registered_user)
|
254
|
+
user: UserRecord = Depends(registered_user)
|
255
|
+
):
|
250
256
|
path = ensure_uri_compnents(path)
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
257
|
+
assert not path.endswith("/"), "Path must not end with /"
|
258
|
+
if not path.startswith(f"{user.username}/"):
|
259
|
+
if not user.is_admin:
|
260
|
+
logger.debug(f"Reject put request from {user.username} to {path}")
|
261
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
262
|
+
else:
|
263
|
+
first_comp = path.split("/")[0]
|
264
|
+
async with unique_cursor() as c:
|
265
|
+
uconn = UserConn(c)
|
266
|
+
owner = await uconn.get_user(first_comp)
|
267
|
+
if not owner:
|
268
|
+
raise HTTPException(status_code=404, detail="Owner not found")
|
261
269
|
|
262
270
|
logger.info(f"PUT {path}, user: {user.username}")
|
263
271
|
exists_flag = False
|
@@ -280,47 +288,73 @@ async def put_file(
|
|
280
288
|
# check content-type
|
281
289
|
content_type = request.headers.get("Content-Type")
|
282
290
|
logger.debug(f"Content-Type: {content_type}")
|
283
|
-
if content_type == "application/json":
|
284
|
-
|
285
|
-
blobs = json.dumps(body).encode('utf-8')
|
286
|
-
elif content_type == "application/x-www-form-urlencoded":
|
287
|
-
# may not work...
|
288
|
-
body = await request.form()
|
289
|
-
file = body.get("file")
|
290
|
-
if isinstance(file, str) or file is None:
|
291
|
-
raise HTTPException(status_code=400, detail="Invalid form data, file required")
|
292
|
-
blobs = await file.read()
|
293
|
-
elif content_type == "application/octet-stream":
|
294
|
-
blobs = await request.body()
|
295
|
-
else:
|
296
|
-
blobs = await request.body()
|
291
|
+
if not (content_type == "application/octet-stream" or content_type == "application/json"):
|
292
|
+
raise HTTPException(status_code=415, detail="Unsupported content type, put request must be application/json or application/octet-stream")
|
297
293
|
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
if mime_t is None:
|
305
|
-
mime_t = "application/octet-stream"
|
306
|
-
|
307
|
-
if len(blobs) > LARGE_FILE_BYTES:
|
308
|
-
async def blob_reader():
|
309
|
-
for b in range(0, len(blobs), CHUNK_SIZE):
|
310
|
-
yield blobs[b:b+CHUNK_SIZE]
|
311
|
-
await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission), mime_type = mime_t)
|
312
|
-
else:
|
313
|
-
await db.save_file(user.id, path, blobs, permission = FileReadPermission(permission), mime_type=mime_t)
|
294
|
+
async def blob_reader():
|
295
|
+
nonlocal request
|
296
|
+
async for chunk in request.stream():
|
297
|
+
yield chunk
|
298
|
+
|
299
|
+
await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
|
314
300
|
|
315
301
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
|
316
|
-
if exists_flag
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
302
|
+
return Response(status_code=200 if exists_flag else 201, headers={
|
303
|
+
"Content-Type": "application/json",
|
304
|
+
}, content=json.dumps({"url": path}))
|
305
|
+
|
306
|
+
# using form-data instead of raw body
|
307
|
+
@router_fs.post("/{path:path}")
|
308
|
+
@handle_exception
|
309
|
+
async def post_file(
|
310
|
+
path: str,
|
311
|
+
file: UploadFile,
|
312
|
+
conflict: Literal["overwrite", "skip", "abort"] = "abort",
|
313
|
+
permission: int = 0,
|
314
|
+
user: UserRecord = Depends(registered_user)
|
315
|
+
):
|
316
|
+
path = ensure_uri_compnents(path)
|
317
|
+
assert not path.endswith("/"), "Path must not end with /"
|
318
|
+
if not path.startswith(f"{user.username}/"):
|
319
|
+
if not user.is_admin:
|
320
|
+
logger.debug(f"Reject put request from {user.username} to {path}")
|
321
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
322
|
+
else:
|
323
|
+
first_comp = path.split("/")[0]
|
324
|
+
async with unique_cursor() as conn:
|
325
|
+
uconn = UserConn(conn)
|
326
|
+
owner = await uconn.get_user(first_comp)
|
327
|
+
if not owner:
|
328
|
+
raise HTTPException(status_code=404, detail="Owner not found")
|
329
|
+
|
330
|
+
logger.info(f"POST {path}, user: {user.username}")
|
331
|
+
exists_flag = False
|
332
|
+
async with unique_cursor() as conn:
|
333
|
+
fconn = FileConn(conn)
|
334
|
+
file_record = await fconn.get_file_record(path)
|
335
|
+
|
336
|
+
if file_record:
|
337
|
+
if conflict == "abort":
|
338
|
+
raise HTTPException(status_code=409, detail="File exists")
|
339
|
+
if conflict == "skip":
|
340
|
+
return Response(status_code=200, headers={
|
341
|
+
"Content-Type": "application/json",
|
342
|
+
}, content=json.dumps({"url": path}))
|
343
|
+
exists_flag = True
|
344
|
+
if not user.is_admin and not file_record.owner_id == user.id:
|
345
|
+
raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
|
346
|
+
await db.delete_file(path)
|
347
|
+
|
348
|
+
async def blob_reader():
|
349
|
+
nonlocal file
|
350
|
+
while (chunk := await file.read(CHUNK_SIZE)):
|
351
|
+
yield chunk
|
352
|
+
|
353
|
+
await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
|
354
|
+
return Response(status_code=200 if exists_flag else 201, headers={
|
355
|
+
"Content-Type": "application/json",
|
356
|
+
}, content=json.dumps({"url": path}))
|
357
|
+
|
324
358
|
|
325
359
|
@router_fs.delete("/{path:path}")
|
326
360
|
@handle_exception
|
@@ -332,11 +366,10 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
|
|
332
366
|
logger.info(f"DELETE {path}, user: {user.username}")
|
333
367
|
|
334
368
|
if path.endswith("/"):
|
335
|
-
res = await db.delete_path(path, user
|
369
|
+
res = await db.delete_path(path, user)
|
336
370
|
else:
|
337
|
-
res = await db.delete_file(path, user
|
371
|
+
res = await db.delete_file(path, user)
|
338
372
|
|
339
|
-
await delayed_log_activity(user.username)
|
340
373
|
if res:
|
341
374
|
return Response(status_code=200, content="Deleted")
|
342
375
|
else:
|
@@ -428,16 +461,15 @@ async def update_file_meta(
|
|
428
461
|
path = ensure_uri_compnents(path)
|
429
462
|
if path.startswith("/"):
|
430
463
|
path = path[1:]
|
431
|
-
await delayed_log_activity(user.username)
|
432
464
|
|
433
465
|
# file
|
434
466
|
if not path.endswith("/"):
|
435
467
|
if perm is not None:
|
436
468
|
logger.info(f"Update permission of {path} to {perm}")
|
437
469
|
await db.update_file_record(
|
438
|
-
user = user,
|
439
470
|
url = path,
|
440
|
-
permission = FileReadPermission(perm)
|
471
|
+
permission = FileReadPermission(perm),
|
472
|
+
op_user = user,
|
441
473
|
)
|
442
474
|
|
443
475
|
if new_path is not None:
|
@@ -452,7 +484,7 @@ async def update_file_meta(
|
|
452
484
|
new_path = ensure_uri_compnents(new_path)
|
453
485
|
logger.info(f"Update path of {path} to {new_path}")
|
454
486
|
# currently only move own file, with overwrite
|
455
|
-
await db.move_path(
|
487
|
+
await db.move_path(path, new_path, user)
|
456
488
|
|
457
489
|
return Response(status_code=200, content="OK")
|
458
490
|
|
@@ -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.2"
|
4
4
|
description = "Lightweight file storage service"
|
5
5
|
authors = ["li, mengxun <limengxun45@outlook.com>"]
|
6
6
|
readme = "Readme.md"
|
@@ -10,12 +10,13 @@ include = ["Readme.md", "docs/*", "frontend/*", "lfss/sql/*"]
|
|
10
10
|
|
11
11
|
[tool.poetry.dependencies]
|
12
12
|
python = ">=3.9"
|
13
|
+
requests = "2.*"
|
13
14
|
aiosqlite = "0.*"
|
14
15
|
aiofiles = "23.*"
|
15
16
|
mimesniff = "1.*"
|
16
17
|
fastapi = "0.*"
|
17
18
|
uvicorn = "0.*"
|
18
|
-
|
19
|
+
python-multipart = "*"
|
19
20
|
pillow = "*"
|
20
21
|
|
21
22
|
[tool.poetry.dev-dependencies]
|
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
|