lfss 0.2.4__py3-none-any.whl → 0.3.1__py3-none-any.whl
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.
- Readme.md +6 -6
- frontend/index.html +5 -3
- frontend/scripts.js +7 -2
- lfss/client/__init__.py +0 -0
- lfss/client/api.py +91 -0
- lfss/src/config.py +2 -1
- lfss/src/database.py +60 -48
- lfss/src/server.py +19 -16
- {lfss-0.2.4.dist-info → lfss-0.3.1.dist-info}/METADATA +7 -7
- {lfss-0.2.4.dist-info → lfss-0.3.1.dist-info}/RECORD +12 -10
- {lfss-0.2.4.dist-info → lfss-0.3.1.dist-info}/WHEEL +0 -0
- {lfss-0.2.4.dist-info → lfss-0.3.1.dist-info}/entry_points.txt +0 -0
Readme.md
CHANGED
@@ -5,7 +5,7 @@ A lightweight file/object storage service!
|
|
5
5
|
|
6
6
|
Usage:
|
7
7
|
```sh
|
8
|
-
pip install
|
8
|
+
pip install lfss
|
9
9
|
lfss-user add <username> <password>
|
10
10
|
lfss-serve
|
11
11
|
```
|
@@ -13,15 +13,15 @@ lfss-serve
|
|
13
13
|
By default, the data will be stored in `.storage_data`.
|
14
14
|
You can change storage directory using the `LFSS_DATA` environment variable.
|
15
15
|
|
16
|
-
I provide a simple client to interact with the service
|
17
|
-
Just start a web server at `/frontend` and open `index.html` in your browser, or use:
|
16
|
+
I provide a simple client to interact with the service:
|
18
17
|
```sh
|
19
|
-
lfss-panel
|
18
|
+
lfss-panel --open
|
20
19
|
```
|
20
|
+
Or, you can start a web server at `/frontend` and open `index.html` in your browser.
|
21
21
|
|
22
22
|
The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
|
23
|
-
Authentication is done via `Authorization` header
|
24
|
-
You can refer to `frontend` as an application example, and `frontend/api.js` for the API usage.
|
23
|
+
Authentication is done via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
|
24
|
+
You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss.client.api.py` for the API usage.
|
25
25
|
|
26
26
|
By default, the service exposes all files to the public for `GET` requests,
|
27
27
|
but file-listing is restricted to the user's own files.
|
frontend/index.html
CHANGED
@@ -48,9 +48,11 @@
|
|
48
48
|
<div class="input-group">
|
49
49
|
<input type="file" id="file-selector" accept="*">
|
50
50
|
<label for="file-name" id="upload-file-prefix"></label>
|
51
|
-
<
|
52
|
-
|
53
|
-
|
51
|
+
<div class="input-group">
|
52
|
+
<input type="text" id="file-name" placeholder="Destination file path" autocomplete="off">
|
53
|
+
<span id='randomize-fname-btn'>🎲</span>
|
54
|
+
<button id="upload-btn">Upload</button>
|
55
|
+
</div>
|
54
56
|
</div>
|
55
57
|
</div>
|
56
58
|
|
frontend/scripts.js
CHANGED
@@ -129,6 +129,9 @@ uploadButton.addEventListener('click', () => {
|
|
129
129
|
refreshFileList();
|
130
130
|
uploadFileNameInput.value = '';
|
131
131
|
onFileNameInpuChange();
|
132
|
+
},
|
133
|
+
(err) => {
|
134
|
+
showPopup('Failed to upload file: ' + err, {level: 'error', timeout: 5000});
|
132
135
|
}
|
133
136
|
);
|
134
137
|
});
|
@@ -261,7 +264,9 @@ function refreshFileList(){
|
|
261
264
|
|
262
265
|
const downloadButton = document.createElement('a');
|
263
266
|
downloadButton.textContent = 'Download';
|
264
|
-
downloadButton.href = conn.config.endpoint + '/_api/bundle?
|
267
|
+
downloadButton.href = conn.config.endpoint + '/_api/bundle?' +
|
268
|
+
'token=' + conn.config.token + '&' +
|
269
|
+
'path=' + dir.url + (dir.url.endsWith('/') ? '' : '/');
|
265
270
|
actContainer.appendChild(downloadButton);
|
266
271
|
|
267
272
|
const deleteButton = document.createElement('a');
|
@@ -401,7 +406,7 @@ function refreshFileList(){
|
|
401
406
|
|
402
407
|
const downloadBtn = document.createElement('a');
|
403
408
|
downloadBtn.textContent = 'Download';
|
404
|
-
downloadBtn.href = conn.config.endpoint + '/' + file.url + '?
|
409
|
+
downloadBtn.href = conn.config.endpoint + '/' + file.url + '?download=true&token=' + conn.config.token;
|
405
410
|
actContainer.appendChild(downloadBtn);
|
406
411
|
|
407
412
|
const deleteButton = document.createElement('a');
|
lfss/client/__init__.py
ADDED
File without changes
|
lfss/client/api.py
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
from typing import Optional, Literal
|
2
|
+
import os
|
3
|
+
import requests
|
4
|
+
import urllib.parse
|
5
|
+
from lfss.src.database import (
|
6
|
+
FileReadPermission, FileRecord, UserRecord, PathContents
|
7
|
+
)
|
8
|
+
|
9
|
+
_default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
|
10
|
+
_default_token = os.environ.get('LFSS_TOKEN', '')
|
11
|
+
|
12
|
+
class Connector:
|
13
|
+
def __init__(self, endpoint=_default_endpoint, token=_default_token):
|
14
|
+
assert token, "No token provided. Please set LFSS_TOKEN environment variable."
|
15
|
+
self.config = {
|
16
|
+
"endpoint": endpoint,
|
17
|
+
"token": token
|
18
|
+
}
|
19
|
+
|
20
|
+
def _fetch(
|
21
|
+
self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
|
22
|
+
path: str, search_params: dict = {}
|
23
|
+
):
|
24
|
+
if path.startswith('/'):
|
25
|
+
path = path[1:]
|
26
|
+
def f(**kwargs):
|
27
|
+
url = f"{self.config['endpoint']}/{path}" + "?" + urllib.parse.urlencode(search_params)
|
28
|
+
headers: dict = kwargs.pop('headers', {})
|
29
|
+
headers.update({
|
30
|
+
'Authorization': f"Bearer {self.config['token']}",
|
31
|
+
})
|
32
|
+
response = requests.request(method, url, headers=headers, **kwargs)
|
33
|
+
response.raise_for_status()
|
34
|
+
return response
|
35
|
+
return f
|
36
|
+
|
37
|
+
def put(self, path: str, file_data: bytes):
|
38
|
+
"""Uploads a file to the specified path."""
|
39
|
+
response = self._fetch('PUT', path)(
|
40
|
+
data=file_data,
|
41
|
+
headers={'Content-Type': 'application/octet-stream'}
|
42
|
+
)
|
43
|
+
return response.json()
|
44
|
+
|
45
|
+
def get(self, path: str) -> Optional[bytes]:
|
46
|
+
"""Downloads a file from the specified path."""
|
47
|
+
try:
|
48
|
+
response = self._fetch('GET', path)()
|
49
|
+
except requests.exceptions.HTTPError as e:
|
50
|
+
if e.response.status_code == 404:
|
51
|
+
return None
|
52
|
+
raise e
|
53
|
+
return response.content
|
54
|
+
|
55
|
+
def delete(self, path: str):
|
56
|
+
"""Deletes the file at the specified path."""
|
57
|
+
if path.startswith('/'):
|
58
|
+
path = path[1:]
|
59
|
+
self._fetch('DELETE', path)()
|
60
|
+
|
61
|
+
def get_metadata(self, path: str) -> Optional[FileRecord]:
|
62
|
+
"""Gets the metadata for the file at the specified path."""
|
63
|
+
try:
|
64
|
+
response = self._fetch('GET', '_api/fmeta', {'path': path})()
|
65
|
+
return FileRecord(**response.json())
|
66
|
+
except requests.exceptions.HTTPError as e:
|
67
|
+
if e.response.status_code == 404:
|
68
|
+
return None
|
69
|
+
raise e
|
70
|
+
|
71
|
+
def list_path(self, path: str) -> PathContents:
|
72
|
+
assert path.endswith('/')
|
73
|
+
response = self._fetch('GET', path)()
|
74
|
+
return PathContents(**response.json())
|
75
|
+
|
76
|
+
def set_file_permission(self, path: str, permission: int | FileReadPermission):
|
77
|
+
"""Sets the file permission for the specified path."""
|
78
|
+
self._fetch('POST', '_api/fmeta', {'path': path, 'perm': int(permission)})(
|
79
|
+
headers={'Content-Type': 'application/www-form-urlencoded'}
|
80
|
+
)
|
81
|
+
|
82
|
+
def move_file(self, path: str, new_path: str):
|
83
|
+
"""Moves a file to a new location."""
|
84
|
+
self._fetch('POST', '_api/fmeta', {'path': path, 'new_path': new_path})(
|
85
|
+
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
86
|
+
)
|
87
|
+
|
88
|
+
def whoami(self) -> UserRecord:
|
89
|
+
"""Gets information about the current user."""
|
90
|
+
response = self._fetch('GET', '_api/whoami')()
|
91
|
+
return UserRecord(**response.json())
|
lfss/src/config.py
CHANGED
lfss/src/database.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
|
2
|
-
from typing import Optional, overload, Literal
|
2
|
+
from typing import Optional, overload, Literal, AsyncIterable
|
3
3
|
from abc import ABC, abstractmethod
|
4
4
|
|
5
5
|
import urllib.parse
|
@@ -24,10 +24,7 @@ def hash_credential(username, password):
|
|
24
24
|
|
25
25
|
_atomic_lock = Lock()
|
26
26
|
def atomic(func):
|
27
|
-
"""
|
28
|
-
Ensure non-reentrancy.
|
29
|
-
Can be skipped if the function only executes a single SQL statement.
|
30
|
-
"""
|
27
|
+
""" Ensure non-reentrancy """
|
31
28
|
@wraps(func)
|
32
29
|
async def wrapper(*args, **kwargs):
|
33
30
|
async with _atomic_lock:
|
@@ -61,7 +58,7 @@ class FileReadPermission(IntEnum):
|
|
61
58
|
PRIVATE = 3 # accessible by owner only (including admin)
|
62
59
|
|
63
60
|
@dataclasses.dataclass
|
64
|
-
class
|
61
|
+
class UserRecord:
|
65
62
|
id: int
|
66
63
|
username: str
|
67
64
|
credential: str
|
@@ -74,12 +71,12 @@ class DBUserRecord:
|
|
74
71
|
def __str__(self):
|
75
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}"
|
76
73
|
|
77
|
-
DECOY_USER =
|
74
|
+
DECOY_USER = UserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
|
78
75
|
class UserConn(DBConnBase):
|
79
76
|
|
80
77
|
@staticmethod
|
81
|
-
def parse_record(record) ->
|
82
|
-
return
|
78
|
+
def parse_record(record) -> UserRecord:
|
79
|
+
return UserRecord(*record)
|
83
80
|
|
84
81
|
async def init(self):
|
85
82
|
await super().init()
|
@@ -105,21 +102,21 @@ class UserConn(DBConnBase):
|
|
105
102
|
|
106
103
|
return self
|
107
104
|
|
108
|
-
async def get_user(self, username: str) -> Optional[
|
105
|
+
async def get_user(self, username: str) -> Optional[UserRecord]:
|
109
106
|
async with self.conn.execute("SELECT * FROM user WHERE username = ?", (username, )) as cursor:
|
110
107
|
res = await cursor.fetchone()
|
111
108
|
|
112
109
|
if res is None: return None
|
113
110
|
return self.parse_record(res)
|
114
111
|
|
115
|
-
async def get_user_by_id(self, user_id: int) -> Optional[
|
112
|
+
async def get_user_by_id(self, user_id: int) -> Optional[UserRecord]:
|
116
113
|
async with self.conn.execute("SELECT * FROM user WHERE id = ?", (user_id, )) as cursor:
|
117
114
|
res = await cursor.fetchone()
|
118
115
|
|
119
116
|
if res is None: return None
|
120
117
|
return self.parse_record(res)
|
121
118
|
|
122
|
-
async def get_user_by_credential(self, credential: str) -> Optional[
|
119
|
+
async def get_user_by_credential(self, credential: str) -> Optional[UserRecord]:
|
123
120
|
async with self.conn.execute("SELECT * FROM user WHERE credential = ?", (credential, )) as cursor:
|
124
121
|
res = await cursor.fetchone()
|
125
122
|
|
@@ -175,15 +172,17 @@ class UserConn(DBConnBase):
|
|
175
172
|
async for record in cursor:
|
176
173
|
yield self.parse_record(record)
|
177
174
|
|
175
|
+
@atomic
|
178
176
|
async def set_active(self, username: str):
|
179
177
|
await self.conn.execute("UPDATE user SET last_active = CURRENT_TIMESTAMP WHERE username = ?", (username, ))
|
180
178
|
|
179
|
+
@atomic
|
181
180
|
async def delete_user(self, username: str):
|
182
181
|
await self.conn.execute("DELETE FROM user WHERE username = ?", (username, ))
|
183
182
|
self.logger.info(f"Delete user {username}")
|
184
183
|
|
185
184
|
@dataclasses.dataclass
|
186
|
-
class
|
185
|
+
class FileRecord:
|
187
186
|
url: str
|
188
187
|
owner_id: int
|
189
188
|
file_id: str # defines mapping from fmata to fdata
|
@@ -203,12 +202,17 @@ class DirectoryRecord:
|
|
203
202
|
|
204
203
|
def __str__(self):
|
205
204
|
return f"Directory {self.url} (size={self.size})"
|
205
|
+
|
206
|
+
@dataclasses.dataclass
|
207
|
+
class PathContents:
|
208
|
+
dirs: list[DirectoryRecord]
|
209
|
+
files: list[FileRecord]
|
206
210
|
|
207
211
|
class FileConn(DBConnBase):
|
208
212
|
|
209
213
|
@staticmethod
|
210
|
-
def parse_record(record) ->
|
211
|
-
return
|
214
|
+
def parse_record(record) -> FileRecord:
|
215
|
+
return FileRecord(*record)
|
212
216
|
|
213
217
|
async def init(self):
|
214
218
|
await super().init()
|
@@ -251,30 +255,30 @@ class FileConn(DBConnBase):
|
|
251
255
|
async with self.conn.execute("SELECT SUM(file_size) FROM fmeta WHERE owner_id = ?", (r[0], )) as cursor:
|
252
256
|
size = await cursor.fetchone()
|
253
257
|
if size is not None and size[0] is not None:
|
254
|
-
await self.
|
258
|
+
await self._user_size_inc(r[0], size[0])
|
255
259
|
|
256
260
|
return self
|
257
261
|
|
258
|
-
async def get_file_record(self, url: str) -> Optional[
|
262
|
+
async def get_file_record(self, url: str) -> Optional[FileRecord]:
|
259
263
|
async with self.conn.execute("SELECT * FROM fmeta WHERE url = ?", (url, )) as cursor:
|
260
264
|
res = await cursor.fetchone()
|
261
265
|
if res is None:
|
262
266
|
return None
|
263
267
|
return self.parse_record(res)
|
264
268
|
|
265
|
-
async def get_file_records(self, urls: list[str]) -> list[
|
269
|
+
async def get_file_records(self, urls: list[str]) -> list[FileRecord]:
|
266
270
|
async with self.conn.execute("SELECT * FROM fmeta WHERE url IN ({})".format(','.join(['?'] * len(urls))), urls) as cursor:
|
267
271
|
res = await cursor.fetchall()
|
268
272
|
if res is None:
|
269
273
|
return []
|
270
274
|
return [self.parse_record(r) for r in res]
|
271
275
|
|
272
|
-
async def get_user_file_records(self, owner_id: int) -> list[
|
276
|
+
async def get_user_file_records(self, owner_id: int) -> list[FileRecord]:
|
273
277
|
async with self.conn.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, )) as cursor:
|
274
278
|
res = await cursor.fetchall()
|
275
279
|
return [self.parse_record(r) for r in res]
|
276
280
|
|
277
|
-
async def get_path_records(self, url: str) -> list[
|
281
|
+
async def get_path_records(self, url: str) -> list[FileRecord]:
|
278
282
|
async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
|
279
283
|
res = await cursor.fetchall()
|
280
284
|
return [self.parse_record(r) for r in res]
|
@@ -297,11 +301,11 @@ class FileConn(DBConnBase):
|
|
297
301
|
return dirs
|
298
302
|
|
299
303
|
@overload
|
300
|
-
async def list_path(self, url: str, flat: Literal[True]) -> list[
|
304
|
+
async def list_path(self, url: str, flat: Literal[True]) -> list[FileRecord]:...
|
301
305
|
@overload
|
302
|
-
async def list_path(self, url: str, flat: Literal[False]) ->
|
306
|
+
async def list_path(self, url: str, flat: Literal[False]) -> PathContents:...
|
303
307
|
|
304
|
-
async def list_path(self, url: str, flat: bool = False) -> list[
|
308
|
+
async def list_path(self, url: str, flat: bool = False) -> list[FileRecord] | PathContents:
|
305
309
|
"""
|
306
310
|
List all files and directories under the given path,
|
307
311
|
if flat is True, return a list of FileDBRecord, recursively including all subdirectories.
|
@@ -318,7 +322,7 @@ class FileConn(DBConnBase):
|
|
318
322
|
return [self.parse_record(r) for r in res]
|
319
323
|
|
320
324
|
else:
|
321
|
-
return (await self.list_root(), [])
|
325
|
+
return PathContents(await self.list_root(), [])
|
322
326
|
|
323
327
|
if flat:
|
324
328
|
async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
|
@@ -346,7 +350,7 @@ class FileConn(DBConnBase):
|
|
346
350
|
dirs_str = [r[0] + '/' for r in res if r[0] != '/']
|
347
351
|
dirs = [DirectoryRecord(url + d, await self.path_size(url + d, include_subpath=True)) for d in dirs_str]
|
348
352
|
|
349
|
-
return (dirs, files)
|
353
|
+
return PathContents(dirs, files)
|
350
354
|
|
351
355
|
async def user_size(self, user_id: int) -> int:
|
352
356
|
async with self.conn.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, )) as cursor:
|
@@ -354,10 +358,10 @@ class FileConn(DBConnBase):
|
|
354
358
|
if res is None:
|
355
359
|
return -1
|
356
360
|
return res[0]
|
357
|
-
async def
|
361
|
+
async def _user_size_inc(self, user_id: int, inc: int):
|
358
362
|
self.logger.debug(f"Increasing user {user_id} size by {inc}")
|
359
363
|
await self.conn.execute("INSERT OR REPLACE INTO usize (user_id, size) VALUES (?, COALESCE((SELECT size FROM usize WHERE user_id = ?), 0) + ?)", (user_id, user_id, inc))
|
360
|
-
async def
|
364
|
+
async def _user_size_dec(self, user_id: int, dec: int):
|
361
365
|
self.logger.debug(f"Decreasing user {user_id} size by {dec}")
|
362
366
|
await self.conn.execute("INSERT OR REPLACE INTO usize (user_id, size) VALUES (?, COALESCE((SELECT size FROM usize WHERE user_id = ?), 0) - ?)", (user_id, user_id, dec))
|
363
367
|
|
@@ -406,7 +410,7 @@ class FileConn(DBConnBase):
|
|
406
410
|
"INSERT INTO fmeta (url, owner_id, file_id, file_size, permission) VALUES (?, ?, ?, ?, ?)",
|
407
411
|
(url, owner_id, file_id, file_size, int(permission))
|
408
412
|
)
|
409
|
-
await self.
|
413
|
+
await self._user_size_inc(owner_id, file_size)
|
410
414
|
self.logger.info(f"File {url} created")
|
411
415
|
|
412
416
|
@atomic
|
@@ -428,7 +432,7 @@ class FileConn(DBConnBase):
|
|
428
432
|
file_record = await self.get_file_record(url)
|
429
433
|
if file_record is None: return
|
430
434
|
await self.conn.execute("DELETE FROM fmeta WHERE url = ?", (url, ))
|
431
|
-
await self.
|
435
|
+
await self._user_size_dec(file_record.owner_id, file_record.file_size)
|
432
436
|
self.logger.info(f"Deleted fmeta {url}")
|
433
437
|
|
434
438
|
@atomic
|
@@ -452,11 +456,12 @@ class FileConn(DBConnBase):
|
|
452
456
|
async with self.conn.execute("SELECT SUM(file_size) FROM fmeta WHERE owner_id = ? AND url LIKE ?", (r[0], path + '%')) as cursor:
|
453
457
|
size = await cursor.fetchone()
|
454
458
|
if size is not None:
|
455
|
-
await self.
|
459
|
+
await self._user_size_dec(r[0], size[0])
|
456
460
|
|
457
461
|
await self.conn.execute("DELETE FROM fmeta WHERE url LIKE ?", (path + '%', ))
|
458
462
|
self.logger.info(f"Deleted {len(all_f_rec)} files for path {path}") # type: ignore
|
459
463
|
|
464
|
+
@atomic
|
460
465
|
async def set_file_blob(self, file_id: str, blob: bytes):
|
461
466
|
await self.conn.execute("INSERT OR REPLACE INTO fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
|
462
467
|
|
@@ -467,9 +472,11 @@ class FileConn(DBConnBase):
|
|
467
472
|
return None
|
468
473
|
return res[0]
|
469
474
|
|
475
|
+
@atomic
|
470
476
|
async def delete_file_blob(self, file_id: str):
|
471
477
|
await self.conn.execute("DELETE FROM fdata WHERE file_id = ?", (file_id, ))
|
472
478
|
|
479
|
+
@atomic
|
473
480
|
async def delete_file_blobs(self, file_ids: list[str]):
|
474
481
|
await self.conn.execute("DELETE FROM fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
|
475
482
|
|
@@ -488,7 +495,7 @@ def validate_url(url: str, is_file = True):
|
|
488
495
|
if not ret:
|
489
496
|
raise InvalidPathError(f"Invalid URL: {url}")
|
490
497
|
|
491
|
-
async def get_user(db: "Database", user: int | str) -> Optional[
|
498
|
+
async def get_user(db: "Database", user: int | str) -> Optional[UserRecord]:
|
492
499
|
if isinstance(user, str):
|
493
500
|
return await db.user.get_user(user)
|
494
501
|
elif isinstance(user, int):
|
@@ -584,7 +591,7 @@ class Database:
|
|
584
591
|
|
585
592
|
return blob
|
586
593
|
|
587
|
-
async def delete_file(self, url: str) -> Optional[
|
594
|
+
async def delete_file(self, url: str) -> Optional[FileRecord]:
|
588
595
|
validate_url(url)
|
589
596
|
|
590
597
|
async with transaction(self):
|
@@ -624,32 +631,37 @@ class Database:
|
|
624
631
|
await self.file.delete_file_blobs([r.file_id for r in records])
|
625
632
|
await self.file.delete_user_file_records(user.id)
|
626
633
|
await self.user.delete_user(user.username)
|
627
|
-
|
628
|
-
async def
|
634
|
+
|
635
|
+
async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes]]:
|
629
636
|
if urls is None:
|
630
637
|
urls = [r.url for r in await self.file.list_path(top_url, flat=True)]
|
631
638
|
|
639
|
+
for url in urls:
|
640
|
+
if not url.startswith(top_url):
|
641
|
+
continue
|
642
|
+
r = await self.file.get_file_record(url)
|
643
|
+
if r is None:
|
644
|
+
continue
|
645
|
+
f_id = r.file_id
|
646
|
+
blob = await self.file.get_file_blob(f_id)
|
647
|
+
if blob is None:
|
648
|
+
self.logger.warning(f"Blob not found for {url}")
|
649
|
+
continue
|
650
|
+
yield r, blob
|
651
|
+
|
652
|
+
async def zip_path(self, top_url: str, urls: Optional[list[str]]) -> io.BytesIO:
|
653
|
+
if top_url.startswith('/'):
|
654
|
+
top_url = top_url[1:]
|
632
655
|
buffer = io.BytesIO()
|
633
656
|
with zipfile.ZipFile(buffer, 'w') as zf:
|
634
|
-
for
|
635
|
-
|
636
|
-
continue
|
637
|
-
r = await self.file.get_file_record(url)
|
638
|
-
if r is None:
|
639
|
-
continue
|
640
|
-
f_id = r.file_id
|
641
|
-
blob = await self.file.get_file_blob(f_id)
|
642
|
-
if blob is None:
|
643
|
-
continue
|
644
|
-
|
645
|
-
rel_path = url[len(top_url):]
|
657
|
+
async for (r, blob) in self.iter_path(top_url, urls):
|
658
|
+
rel_path = r.url[len(top_url):]
|
646
659
|
rel_path = decode_uri_compnents(rel_path)
|
647
660
|
zf.writestr(rel_path, blob)
|
648
|
-
|
649
661
|
buffer.seek(0)
|
650
662
|
return buffer
|
651
663
|
|
652
|
-
def check_user_permission(user:
|
664
|
+
def check_user_permission(user: UserRecord, owner: UserRecord, file: FileRecord) -> tuple[bool, str]:
|
653
665
|
if user.is_admin:
|
654
666
|
return True, ""
|
655
667
|
|
lfss/src/server.py
CHANGED
@@ -13,9 +13,9 @@ from contextlib import asynccontextmanager
|
|
13
13
|
|
14
14
|
from .error import *
|
15
15
|
from .log import get_logger
|
16
|
-
from .config import MAX_BUNDLE_BYTES
|
16
|
+
from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES
|
17
17
|
from .utils import ensure_uri_compnents
|
18
|
-
from .database import Database,
|
18
|
+
from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission
|
19
19
|
|
20
20
|
logger = get_logger("server")
|
21
21
|
conn = Database()
|
@@ -78,7 +78,7 @@ app.add_middleware(
|
|
78
78
|
router_fs = APIRouter(prefix="")
|
79
79
|
|
80
80
|
@router_fs.get("/{path:path}")
|
81
|
-
async def get_file(path: str,
|
81
|
+
async def get_file(path: str, download = False, user: UserRecord = Depends(get_current_user)):
|
82
82
|
path = ensure_uri_compnents(path)
|
83
83
|
|
84
84
|
# handle directory query
|
@@ -97,11 +97,7 @@ async def get_file(path: str, asfile = False, user: DBUserRecord = Depends(get_c
|
|
97
97
|
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
98
98
|
raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
|
99
99
|
|
100
|
-
|
101
|
-
return {
|
102
|
-
"dirs": dirs,
|
103
|
-
"files": files
|
104
|
-
}
|
100
|
+
return await conn.file.list_path(path, flat = False)
|
105
101
|
|
106
102
|
file_record = await conn.file.get_file_record(path)
|
107
103
|
if not file_record:
|
@@ -128,13 +124,13 @@ async def get_file(path: str, asfile = False, user: DBUserRecord = Depends(get_c
|
|
128
124
|
}
|
129
125
|
)
|
130
126
|
|
131
|
-
if
|
127
|
+
if download:
|
132
128
|
return await send('application/octet-stream', "attachment")
|
133
129
|
else:
|
134
130
|
return await send(None, "inline")
|
135
131
|
|
136
132
|
@router_fs.put("/{path:path}")
|
137
|
-
async def put_file(request: Request, path: str, user:
|
133
|
+
async def put_file(request: Request, path: str, user: UserRecord = Depends(get_current_user)):
|
138
134
|
path = ensure_uri_compnents(path)
|
139
135
|
if user.id == 0:
|
140
136
|
logger.debug("Reject put request from DECOY_USER")
|
@@ -143,6 +139,13 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
|
|
143
139
|
logger.debug(f"Reject put request from {user.username} to {path}")
|
144
140
|
raise HTTPException(status_code=403, detail="Permission denied")
|
145
141
|
|
142
|
+
content_length = request.headers.get("Content-Length")
|
143
|
+
if content_length is not None:
|
144
|
+
content_length = int(content_length)
|
145
|
+
if content_length > MAX_FILE_BYTES:
|
146
|
+
logger.debug(f"Reject put request from {user.username} to {path}, file too large")
|
147
|
+
raise HTTPException(status_code=413, detail="File too large")
|
148
|
+
|
146
149
|
logger.info(f"PUT {path}, user: {user.username}")
|
147
150
|
exists_flag = False
|
148
151
|
file_record = await conn.file.get_file_record(path)
|
@@ -182,7 +185,7 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
|
|
182
185
|
}, content=json.dumps({"url": path}))
|
183
186
|
|
184
187
|
@router_fs.delete("/{path:path}")
|
185
|
-
async def delete_file(path: str, user:
|
188
|
+
async def delete_file(path: str, user: UserRecord = Depends(get_current_user)):
|
186
189
|
path = ensure_uri_compnents(path)
|
187
190
|
if user.id == 0:
|
188
191
|
raise HTTPException(status_code=401, detail="Permission denied")
|
@@ -204,7 +207,7 @@ async def delete_file(path: str, user: DBUserRecord = Depends(get_current_user))
|
|
204
207
|
router_api = APIRouter(prefix="/_api")
|
205
208
|
|
206
209
|
@router_api.get("/bundle")
|
207
|
-
async def bundle_files(path: str, user:
|
210
|
+
async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
|
208
211
|
logger.info(f"GET bundle({path}), user: {user.username}")
|
209
212
|
if user.id == 0:
|
210
213
|
raise HTTPException(status_code=401, detail="Permission denied")
|
@@ -215,7 +218,7 @@ async def bundle_files(path: str, user: DBUserRecord = Depends(get_current_user)
|
|
215
218
|
path = path[1:]
|
216
219
|
|
217
220
|
owner_records_cache = {} # cache owner records, ID -> UserRecord
|
218
|
-
async def is_access_granted(file_record:
|
221
|
+
async def is_access_granted(file_record: FileRecord):
|
219
222
|
owner_id = file_record.owner_id
|
220
223
|
owner = owner_records_cache.get(owner_id, None)
|
221
224
|
if owner is None:
|
@@ -246,7 +249,7 @@ async def bundle_files(path: str, user: DBUserRecord = Depends(get_current_user)
|
|
246
249
|
)
|
247
250
|
|
248
251
|
@router_api.get("/fmeta")
|
249
|
-
async def get_file_meta(path: str, user:
|
252
|
+
async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user)):
|
250
253
|
logger.info(f"GET meta({path}), user: {user.username}")
|
251
254
|
if path.endswith("/"):
|
252
255
|
raise HTTPException(status_code=400, detail="Invalid path")
|
@@ -261,7 +264,7 @@ async def update_file_meta(
|
|
261
264
|
path: str,
|
262
265
|
perm: Optional[int] = None,
|
263
266
|
new_path: Optional[str] = None,
|
264
|
-
user:
|
267
|
+
user: UserRecord = Depends(get_current_user)
|
265
268
|
):
|
266
269
|
if user.id == 0:
|
267
270
|
raise HTTPException(status_code=401, detail="Permission denied")
|
@@ -290,7 +293,7 @@ async def update_file_meta(
|
|
290
293
|
return Response(status_code=200, content="OK")
|
291
294
|
|
292
295
|
@router_api.get("/whoami")
|
293
|
-
async def whoami(user:
|
296
|
+
async def whoami(user: UserRecord = Depends(get_current_user)):
|
294
297
|
if user.id == 0:
|
295
298
|
raise HTTPException(status_code=401, detail="Login required")
|
296
299
|
user.credential = "__HIDDEN__"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.3.1
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li, mengxun
|
@@ -24,7 +24,7 @@ A lightweight file/object storage service!
|
|
24
24
|
|
25
25
|
Usage:
|
26
26
|
```sh
|
27
|
-
pip install
|
27
|
+
pip install lfss
|
28
28
|
lfss-user add <username> <password>
|
29
29
|
lfss-serve
|
30
30
|
```
|
@@ -32,15 +32,15 @@ lfss-serve
|
|
32
32
|
By default, the data will be stored in `.storage_data`.
|
33
33
|
You can change storage directory using the `LFSS_DATA` environment variable.
|
34
34
|
|
35
|
-
I provide a simple client to interact with the service
|
36
|
-
Just start a web server at `/frontend` and open `index.html` in your browser, or use:
|
35
|
+
I provide a simple client to interact with the service:
|
37
36
|
```sh
|
38
|
-
lfss-panel
|
37
|
+
lfss-panel --open
|
39
38
|
```
|
39
|
+
Or, you can start a web server at `/frontend` and open `index.html` in your browser.
|
40
40
|
|
41
41
|
The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
|
42
|
-
Authentication is done via `Authorization` header
|
43
|
-
You can refer to `frontend` as an application example, and `frontend/api.js` for the API usage.
|
42
|
+
Authentication is done via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
|
43
|
+
You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss.client.api.py` for the API usage.
|
44
44
|
|
45
45
|
By default, the service exposes all files to the public for `GET` requests,
|
46
46
|
but file-listing is restricted to the user's own files.
|
@@ -1,24 +1,26 @@
|
|
1
|
-
Readme.md,sha256=
|
1
|
+
Readme.md,sha256=8ZAADV1K20gEDhwiL-Qq_rBAePlwWQn8c5uJ_X7OCIg,1128
|
2
2
|
docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
|
3
3
|
docs/Permission.md,sha256=EY1Y4tT5PDdHDb2pXsVgMAJXxkUihTZfPT0_z7Q53FA,1485
|
4
4
|
frontend/api.js,sha256=D_MaQlmBGzzSR30a23Kh3DxPeF8MpKYe5uXSb9srRTU,7281
|
5
|
-
frontend/index.html,sha256=
|
5
|
+
frontend/index.html,sha256=VPJDs2LG8ep9kjlsKzjWzpN9vc1VGgdvOUlNTZWyQoQ,2088
|
6
6
|
frontend/popup.css,sha256=VzkjG1ZTLxhHMtTyobnlvqYmVsTmdbJJed2Pu1cc06c,1007
|
7
7
|
frontend/popup.js,sha256=dH5n7C2Vo9gCsMfQ4ajL4D1ETl0Wk9rIldxUb7x0f_c,4634
|
8
|
-
frontend/scripts.js,sha256=
|
8
|
+
frontend/scripts.js,sha256=1Cd5cb84SWolozA0Q1MpWaXiAqeQ5R6qZyk7gdoRPgU,18266
|
9
9
|
frontend/styles.css,sha256=Ql_-W5dbh6LlJL2Kez8qsqPSF2fckFgmOYP4SMfKJVs,4065
|
10
10
|
frontend/utils.js,sha256=biE2te5ezswZyuwlDTYbHEt7VWKfCcUrusNt1lHjkLw,2263
|
11
11
|
lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
|
12
12
|
lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
|
13
13
|
lfss/cli/user.py,sha256=906MIQO1mr4XXzPpT8Kfri1G61bWlgjDzIglxypQ7z0,3251
|
14
|
+
lfss/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
15
|
+
lfss/client/api.py,sha256=VBiU-JxGaN6S-fXYD_KvzG7FsGBIUzZ5IisJJSMooDk,3443
|
14
16
|
lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
15
|
-
lfss/src/config.py,sha256=
|
16
|
-
lfss/src/database.py,sha256=
|
17
|
+
lfss/src/config.py,sha256=mc8QoiWoPgiZ5JnSvKBH8jWbp5uLSyG3l5boDiQPXS0,325
|
18
|
+
lfss/src/database.py,sha256=jsDcDrc5r4mnuFiY10YEMwIRMvx6c4PY4LN9C9BinDA,27854
|
17
19
|
lfss/src/error.py,sha256=S5ui3tJ0uKX4EZnt2Db-KbDmJXlECI_OY95cNMkuegc,218
|
18
20
|
lfss/src/log.py,sha256=7mRHFwhx7GKtm_cRryoEIlRQhHTLQC3Qd-N81YsoKao,5174
|
19
|
-
lfss/src/server.py,sha256=
|
21
|
+
lfss/src/server.py,sha256=lAGfwHZasxKl9UIj8xAlTqTjMBOffU94oxOOXWuwiXM,11852
|
20
22
|
lfss/src/utils.py,sha256=MrjKc8W2Y7AbgVGadSNAA50tRMbGYWRrA4KUhOCwuUU,694
|
21
|
-
lfss-0.
|
22
|
-
lfss-0.
|
23
|
-
lfss-0.
|
24
|
-
lfss-0.
|
23
|
+
lfss-0.3.1.dist-info/METADATA,sha256=6qCJopZvi-0F5E8qJhipUjYG7vVZeVivDNPMOjkH5bU,1787
|
24
|
+
lfss-0.3.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
25
|
+
lfss-0.3.1.dist-info/entry_points.txt,sha256=nUIJhenyZbcymvPVhrzV7SAtRhd7O52DflbRrpQUC04,110
|
26
|
+
lfss-0.3.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|