lfss 0.3.2__tar.gz → 0.4.1__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.3.2 → lfss-0.4.1}/PKG-INFO +1 -1
- {lfss-0.3.2 → lfss-0.4.1}/frontend/api.js +8 -2
- {lfss-0.3.2 → lfss-0.4.1}/frontend/scripts.js +1 -1
- lfss-0.4.1/lfss/cli/cli.py +48 -0
- lfss-0.4.1/lfss/client/__init__.py +58 -0
- {lfss-0.3.2 → lfss-0.4.1}/lfss/client/api.py +7 -2
- {lfss-0.3.2 → lfss-0.4.1}/lfss/src/database.py +7 -6
- {lfss-0.3.2 → lfss-0.4.1}/lfss/src/server.py +18 -8
- {lfss-0.3.2 → lfss-0.4.1}/lfss/src/stat.py +6 -5
- {lfss-0.3.2 → lfss-0.4.1}/lfss/src/utils.py +18 -2
- {lfss-0.3.2 → lfss-0.4.1}/pyproject.toml +2 -1
- lfss-0.3.2/lfss/src/__init__.py +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/Readme.md +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/docs/Known_issues.md +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/docs/Permission.md +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/frontend/index.html +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/frontend/popup.css +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/frontend/popup.js +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/frontend/styles.css +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/frontend/utils.js +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/lfss/cli/panel.py +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/lfss/cli/serve.py +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/lfss/cli/user.py +0 -0
- {lfss-0.3.2/lfss/client → lfss-0.4.1/lfss/src}/__init__.py +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/lfss/src/config.py +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/lfss/src/error.py +0 -0
- {lfss-0.3.2 → lfss-0.4.1}/lfss/src/log.py +0 -0
@@ -54,10 +54,16 @@ export default class Connector {
|
|
54
54
|
* @param {File} file - the file to upload
|
55
55
|
* @returns {Promise<string>} - the promise of the request, the url of the file
|
56
56
|
*/
|
57
|
-
async put(path, file
|
57
|
+
async put(path, file, {
|
58
|
+
overwrite = false,
|
59
|
+
permission = 0
|
60
|
+
} = {}){
|
58
61
|
if (path.startsWith('/')){ path = path.slice(1); }
|
59
62
|
const fileBytes = await file.arrayBuffer();
|
60
|
-
const
|
63
|
+
const dst = new URL(this.config.endpoint + '/' + path);
|
64
|
+
dst.searchParams.append('overwrite', overwrite);
|
65
|
+
dst.searchParams.append('permission', permission);
|
66
|
+
const res = await fetch(dst.toString(), {
|
61
67
|
method: 'PUT',
|
62
68
|
headers: {
|
63
69
|
'Authorization': 'Bearer ' + this.config.token,
|
@@ -172,7 +172,7 @@ Are you sure you want to proceed?
|
|
172
172
|
async function uploadFile(...args){
|
173
173
|
const [file, path] = args;
|
174
174
|
try{
|
175
|
-
await conn.put(path, file);
|
175
|
+
await conn.put(path, file, {overwrite: true});
|
176
176
|
}
|
177
177
|
catch (err){
|
178
178
|
showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
|
@@ -0,0 +1,48 @@
|
|
1
|
+
from lfss.client import Connector, upload_directory
|
2
|
+
from lfss.src.database import FileReadPermission
|
3
|
+
from pathlib import Path
|
4
|
+
import argparse
|
5
|
+
|
6
|
+
def parse_arguments():
|
7
|
+
parser = argparse.ArgumentParser(description="Command line interface, please set LFSS_ENDPOINT and LFSS_TOKEN environment variables.")
|
8
|
+
|
9
|
+
sp = parser.add_subparsers(dest="command", required=True)
|
10
|
+
|
11
|
+
# upload
|
12
|
+
sp_upload = sp.add_parser("upload", help="Upload files")
|
13
|
+
sp_upload.add_argument("src", help="Source file or directory", type=str)
|
14
|
+
sp_upload.add_argument("dst", help="Destination path", type=str)
|
15
|
+
sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
|
16
|
+
sp_upload.add_argument("--interval", type=float, default=0, help="Interval between retries, only works with directory upload")
|
17
|
+
sp_upload.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
|
18
|
+
sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
|
19
|
+
sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
|
20
|
+
|
21
|
+
return parser.parse_args()
|
22
|
+
|
23
|
+
def main():
|
24
|
+
args = parse_arguments()
|
25
|
+
connector = Connector()
|
26
|
+
if args.command == "upload":
|
27
|
+
src_path = Path(args.src)
|
28
|
+
if src_path.is_dir():
|
29
|
+
upload_directory(
|
30
|
+
connector, args.src, args.dst,
|
31
|
+
verbose=True,
|
32
|
+
n_concurrent=args.jobs,
|
33
|
+
n_reties=args.retries,
|
34
|
+
interval=args.interval,
|
35
|
+
overwrite=args.overwrite,
|
36
|
+
permission=args.permission
|
37
|
+
)
|
38
|
+
else:
|
39
|
+
with open(args.src, 'rb') as f:
|
40
|
+
connector.put(
|
41
|
+
args.dst,
|
42
|
+
f.read(),
|
43
|
+
overwrite=args.overwrite,
|
44
|
+
permission=args.permission
|
45
|
+
)
|
46
|
+
else:
|
47
|
+
raise NotImplementedError(f"Command {args.command} not implemented.")
|
48
|
+
|
@@ -0,0 +1,58 @@
|
|
1
|
+
import os, time
|
2
|
+
from threading import Lock
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
4
|
+
from .api import Connector
|
5
|
+
|
6
|
+
def upload_directory(
|
7
|
+
connector: Connector,
|
8
|
+
directory: str,
|
9
|
+
path: str,
|
10
|
+
n_concurrent: int = 1,
|
11
|
+
n_reties: int = 0,
|
12
|
+
interval: float = 0,
|
13
|
+
verbose: bool = False,
|
14
|
+
**put_kwargs
|
15
|
+
) -> list[str]:
|
16
|
+
assert path.endswith('/'), "Path must end with a slash."
|
17
|
+
if path.startswith('/'):
|
18
|
+
path = path[1:]
|
19
|
+
|
20
|
+
_counter = 0
|
21
|
+
_counter_lock = Lock()
|
22
|
+
|
23
|
+
faild_files = []
|
24
|
+
def put_file(file_path):
|
25
|
+
with _counter_lock:
|
26
|
+
nonlocal _counter
|
27
|
+
_counter += 1
|
28
|
+
this_count = _counter
|
29
|
+
dst_path = f"{path}{os.path.relpath(file_path, directory)}"
|
30
|
+
if verbose:
|
31
|
+
print(f"[{this_count}] Uploading {file_path} to {dst_path}")
|
32
|
+
|
33
|
+
this_try = 0
|
34
|
+
with open(file_path, 'rb') as f:
|
35
|
+
blob = f.read()
|
36
|
+
|
37
|
+
while this_try <= n_reties:
|
38
|
+
try:
|
39
|
+
connector.put(dst_path, blob, **put_kwargs)
|
40
|
+
break
|
41
|
+
except Exception as e:
|
42
|
+
if verbose:
|
43
|
+
print(f"[{this_count}] Error uploading {file_path}: {e}, retrying...")
|
44
|
+
this_try += 1
|
45
|
+
finally:
|
46
|
+
time.sleep(interval)
|
47
|
+
|
48
|
+
if this_try > n_reties:
|
49
|
+
faild_files.append(file_path)
|
50
|
+
if verbose:
|
51
|
+
print(f"[{this_count}] Failed to upload {file_path} after {n_reties} retries.")
|
52
|
+
|
53
|
+
with ThreadPoolExecutor(n_concurrent) as executor:
|
54
|
+
for root, dirs, files in os.walk(directory):
|
55
|
+
for file in files:
|
56
|
+
executor.submit(put_file, os.path.join(root, file))
|
57
|
+
|
58
|
+
return faild_files
|
@@ -34,9 +34,14 @@ class Connector:
|
|
34
34
|
return response
|
35
35
|
return f
|
36
36
|
|
37
|
-
def put(self, path: str, file_data: bytes):
|
37
|
+
def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, overwrite: bool = False):
|
38
38
|
"""Uploads a file to the specified path."""
|
39
|
-
|
39
|
+
if path.startswith('/'):
|
40
|
+
path = path[1:]
|
41
|
+
response = self._fetch('PUT', path, search_params={
|
42
|
+
'permission': int(permission),
|
43
|
+
'overwrite': overwrite
|
44
|
+
})(
|
40
45
|
data=file_data,
|
41
46
|
headers={'Content-Type': 'application/octet-stream'}
|
42
47
|
)
|
@@ -69,7 +69,7 @@ class UserRecord:
|
|
69
69
|
permission: 'FileReadPermission'
|
70
70
|
|
71
71
|
def __str__(self):
|
72
|
-
return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}
|
72
|
+
return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}, storage={self.max_storage}, permission={self.permission})"
|
73
73
|
|
74
74
|
DECOY_USER = UserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
|
75
75
|
class UserConn(DBConnBase):
|
@@ -421,7 +421,7 @@ class FileConn(DBConnBase):
|
|
421
421
|
new_exists = await self.get_file_record(new_url)
|
422
422
|
if new_exists is not None:
|
423
423
|
raise FileExistsError(f"File {new_url} already exists")
|
424
|
-
async with self.conn.execute("UPDATE fmeta SET url =
|
424
|
+
async with self.conn.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url)):
|
425
425
|
self.logger.info(f"Moved file {old_url} to {new_url}")
|
426
426
|
|
427
427
|
async def log_access(self, url: str):
|
@@ -481,8 +481,9 @@ class FileConn(DBConnBase):
|
|
481
481
|
await self.conn.execute("DELETE FROM fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
|
482
482
|
|
483
483
|
def validate_url(url: str, is_file = True):
|
484
|
-
|
485
|
-
|
484
|
+
prohibited_chars = ['..', ';', "'", '"', '\\', '\0', '\n', '\r', '\t', '\x0b', '\x0c']
|
485
|
+
ret = not url.startswith('/') and not url.startswith('_') and not url.startswith('.')
|
486
|
+
ret = ret and not any([c in url for c in prohibited_chars])
|
486
487
|
|
487
488
|
if not ret:
|
488
489
|
raise InvalidPathError(f"Invalid URL: {url}")
|
@@ -542,7 +543,7 @@ class Database:
|
|
542
543
|
if _g_conn is not None:
|
543
544
|
await _g_conn.rollback()
|
544
545
|
|
545
|
-
async def save_file(self, u: int | str, url: str, blob: bytes):
|
546
|
+
async def save_file(self, u: int | str, url: str, blob: bytes, permission: FileReadPermission = FileReadPermission.UNSET):
|
546
547
|
validate_url(url)
|
547
548
|
assert isinstance(blob, bytes), "blob must be bytes"
|
548
549
|
|
@@ -570,7 +571,7 @@ class Database:
|
|
570
571
|
f_id = uuid.uuid4().hex
|
571
572
|
async with transaction(self):
|
572
573
|
await self.file.set_file_blob(f_id, blob)
|
573
|
-
await self.file.set_file_record(url, owner_id=user.id, file_id=f_id, file_size=file_size)
|
574
|
+
await self.file.set_file_record(url, owner_id=user.id, file_id=f_id, file_size=file_size, permission=permission)
|
574
575
|
await self.user.set_active(user.username)
|
575
576
|
|
576
577
|
# async def read_file_stream(self, url: str): ...
|
@@ -15,7 +15,7 @@ from .error import *
|
|
15
15
|
from .log import get_logger
|
16
16
|
from .stat import RequestDB
|
17
17
|
from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES
|
18
|
-
from .utils import ensure_uri_compnents
|
18
|
+
from .utils import ensure_uri_compnents, format_last_modified, now_stamp
|
19
19
|
from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission
|
20
20
|
|
21
21
|
logger = get_logger("server", term_level="DEBUG")
|
@@ -81,6 +81,7 @@ app.add_middleware(
|
|
81
81
|
@app.middleware("http")
|
82
82
|
async def log_requests(request: Request, call_next):
|
83
83
|
|
84
|
+
request_time_stamp = now_stamp()
|
84
85
|
start_time = time.perf_counter()
|
85
86
|
response: Response = await call_next(request)
|
86
87
|
end_time = time.perf_counter()
|
@@ -91,6 +92,7 @@ async def log_requests(request: Request, call_next):
|
|
91
92
|
logger_failed_request.error(f"{request.method} {request.url.path} {response.status_code}")
|
92
93
|
|
93
94
|
await req_conn.log_request(
|
95
|
+
request_time_stamp,
|
94
96
|
request.method, request.url.path, response.status_code, response_time,
|
95
97
|
headers = dict(request.headers),
|
96
98
|
query = dict(request.query_params),
|
@@ -148,7 +150,8 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
|
|
148
150
|
return Response(
|
149
151
|
content=fblob, media_type=media_type, headers={
|
150
152
|
"Content-Disposition": f"{disposition}; filename={fname}",
|
151
|
-
"Content-Length": str(len(fblob))
|
153
|
+
"Content-Length": str(len(fblob)),
|
154
|
+
"Last-Modified": format_last_modified(file_record.create_time)
|
152
155
|
}
|
153
156
|
)
|
154
157
|
|
@@ -159,7 +162,12 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
|
|
159
162
|
|
160
163
|
@router_fs.put("/{path:path}")
|
161
164
|
@handle_exception
|
162
|
-
async def put_file(
|
165
|
+
async def put_file(
|
166
|
+
request: Request,
|
167
|
+
path: str,
|
168
|
+
overwrite: Optional[bool] = False,
|
169
|
+
permission: int = 0,
|
170
|
+
user: UserRecord = Depends(get_current_user)):
|
163
171
|
path = ensure_uri_compnents(path)
|
164
172
|
if user.id == 0:
|
165
173
|
logger.debug("Reject put request from DECOY_USER")
|
@@ -179,8 +187,10 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
|
|
179
187
|
exists_flag = False
|
180
188
|
file_record = await conn.file.get_file_record(path)
|
181
189
|
if file_record:
|
182
|
-
|
190
|
+
if not overwrite:
|
191
|
+
raise HTTPException(status_code=409, detail="File exists")
|
183
192
|
# remove the old file
|
193
|
+
exists_flag = True
|
184
194
|
await conn.delete_file(path)
|
185
195
|
|
186
196
|
# check content-type
|
@@ -188,20 +198,20 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
|
|
188
198
|
logger.debug(f"Content-Type: {content_type}")
|
189
199
|
if content_type == "application/json":
|
190
200
|
body = await request.json()
|
191
|
-
await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'))
|
201
|
+
await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'), permission = FileReadPermission(permission))
|
192
202
|
elif content_type == "application/x-www-form-urlencoded":
|
193
203
|
# may not work...
|
194
204
|
body = await request.form()
|
195
205
|
file = body.get("file")
|
196
206
|
if isinstance(file, str) or file is None:
|
197
207
|
raise HTTPException(status_code=400, detail="Invalid form data, file required")
|
198
|
-
await conn.save_file(user.id, path, await file.read())
|
208
|
+
await conn.save_file(user.id, path, await file.read(), permission = FileReadPermission(permission))
|
199
209
|
elif content_type == "application/octet-stream":
|
200
210
|
body = await request.body()
|
201
|
-
await conn.save_file(user.id, path, body)
|
211
|
+
await conn.save_file(user.id, path, body, permission = FileReadPermission(permission))
|
202
212
|
else:
|
203
213
|
body = await request.body()
|
204
|
-
await conn.save_file(user.id, path, body)
|
214
|
+
await conn.save_file(user.id, path, body, permission = FileReadPermission(permission))
|
205
215
|
|
206
216
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
|
207
217
|
if exists_flag:
|
@@ -13,7 +13,7 @@ class RequestDB:
|
|
13
13
|
await self.conn.execute('''
|
14
14
|
CREATE TABLE IF NOT EXISTS requests (
|
15
15
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
16
|
-
time
|
16
|
+
time FLOAT DEFAULT (strftime('%s', 'now')),
|
17
17
|
method TEXT,
|
18
18
|
path TEXT,
|
19
19
|
headers TEXT,
|
@@ -43,7 +43,8 @@ class RequestDB:
|
|
43
43
|
await self.commit()
|
44
44
|
|
45
45
|
async def log_request(
|
46
|
-
self,
|
46
|
+
self, time: float,
|
47
|
+
method: str, path: str,
|
47
48
|
status: int, duration: float,
|
48
49
|
headers: Optional[Any] = None,
|
49
50
|
query: Optional[Any] = None,
|
@@ -57,9 +58,9 @@ class RequestDB:
|
|
57
58
|
client = str(client)
|
58
59
|
async with self.conn.execute('''
|
59
60
|
INSERT INTO requests (
|
60
|
-
method, path, headers, query, client, duration, request_size, response_size, status
|
61
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
62
|
-
''', (method, path, headers, query, client, duration, request_size, response_size, status)) as cursor:
|
61
|
+
time, method, path, headers, query, client, duration, request_size, response_size, status
|
62
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
63
|
+
''', (time, method, path, headers, query, client, duration, request_size, response_size, status)) as cursor:
|
63
64
|
assert cursor.lastrowid is not None
|
64
65
|
return cursor.lastrowid
|
65
66
|
|
@@ -1,4 +1,4 @@
|
|
1
|
-
|
1
|
+
import datetime
|
2
2
|
import urllib.parse
|
3
3
|
import asyncio
|
4
4
|
import functools
|
@@ -49,4 +49,20 @@ def debounce_async(delay: float = 0):
|
|
49
49
|
except asyncio.CancelledError:
|
50
50
|
pass
|
51
51
|
return wrapper
|
52
|
-
return debounce_wrap
|
52
|
+
return debounce_wrap
|
53
|
+
|
54
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
|
55
|
+
def format_last_modified(last_modified_gmt: str):
|
56
|
+
"""
|
57
|
+
Format the last modified time to the HTTP standard format
|
58
|
+
- last_modified_gmt: The last modified time in SQLite ISO 8601 GMT format: e.g. '2021-09-01 12:00:00'
|
59
|
+
"""
|
60
|
+
assert len(last_modified_gmt) == 19
|
61
|
+
dt = datetime.datetime.strptime(last_modified_gmt, '%Y-%m-%d %H:%M:%S')
|
62
|
+
return dt.strftime('%a, %d %b %Y %H:%M:%S GMT')
|
63
|
+
|
64
|
+
def now_stamp() -> float:
|
65
|
+
return datetime.datetime.now().timestamp()
|
66
|
+
|
67
|
+
def stamp_to_str(stamp: float) -> str:
|
68
|
+
return datetime.datetime.fromtimestamp(stamp).strftime('%Y-%m-%d %H:%M:%S')
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "lfss"
|
3
|
-
version = "0.
|
3
|
+
version = "0.4.1"
|
4
4
|
description = "Lightweight file storage service"
|
5
5
|
authors = ["li, mengxun <limengxun45@outlook.com>"]
|
6
6
|
readme = "Readme.md"
|
@@ -18,6 +18,7 @@ mimesniff = "1.*"
|
|
18
18
|
lfss-serve = "lfss.cli.serve:main"
|
19
19
|
lfss-user = "lfss.cli.user:main"
|
20
20
|
lfss-panel = "lfss.cli.panel:main"
|
21
|
+
lfss-cli = "lfss.cli.cli:main"
|
21
22
|
|
22
23
|
[build-system]
|
23
24
|
requires = ["poetry-core>=1.0.0"]
|
lfss-0.3.2/lfss/src/__init__.py
DELETED
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
|