lfss 0.1.0__py3-none-any.whl → 0.2.3__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 +28 -0
- docs/Known_issues.md +1 -0
- docs/Permission.md +29 -0
- frontend/api.js +204 -0
- frontend/index.html +60 -0
- frontend/popup.css +30 -0
- frontend/popup.js +89 -0
- frontend/scripts.js +443 -0
- frontend/styles.css +212 -0
- frontend/utils.js +83 -0
- lfss/cli/panel.py +45 -0
- lfss/cli/user.py +16 -3
- lfss/src/database.py +203 -48
- lfss/src/error.py +8 -0
- lfss/src/server.py +110 -29
- {lfss-0.1.0.dist-info → lfss-0.2.3.dist-info}/METADATA +13 -8
- lfss-0.2.3.dist-info/RECORD +24 -0
- {lfss-0.1.0.dist-info → lfss-0.2.3.dist-info}/entry_points.txt +1 -0
- lfss-0.1.0.dist-info/RECORD +0 -12
- {lfss-0.1.0.dist-info → lfss-0.2.3.dist-info}/WHEEL +0 -0
lfss/src/server.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
from typing import Optional
|
2
|
+
from functools import wraps
|
2
3
|
|
3
4
|
from fastapi import FastAPI, APIRouter, Depends, Request, Response
|
4
5
|
from fastapi.exceptions import HTTPException
|
@@ -10,10 +11,11 @@ import json
|
|
10
11
|
import mimetypes
|
11
12
|
from contextlib import asynccontextmanager
|
12
13
|
|
14
|
+
from .error import *
|
13
15
|
from .log import get_logger
|
14
16
|
from .config import MAX_BUNDLE_BYTES
|
15
17
|
from .utils import ensure_uri_compnents
|
16
|
-
from .database import Database, DBUserRecord, DECOY_USER, FileReadPermission
|
18
|
+
from .database import Database, DBUserRecord, DECOY_USER, FileDBRecord, check_user_permission, FileReadPermission
|
17
19
|
|
18
20
|
logger = get_logger("server")
|
19
21
|
conn = Database()
|
@@ -23,17 +25,48 @@ async def lifespan(app: FastAPI):
|
|
23
25
|
global conn
|
24
26
|
await conn.init()
|
25
27
|
yield
|
28
|
+
await conn.commit()
|
26
29
|
await conn.close()
|
27
30
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
31
|
+
def handle_exception(fn):
|
32
|
+
@wraps(fn)
|
33
|
+
async def wrapper(*args, **kwargs):
|
34
|
+
try:
|
35
|
+
return await fn(*args, **kwargs)
|
36
|
+
except Exception as e:
|
37
|
+
logger.error(f"Error in {fn.__name__}: {e}")
|
38
|
+
if isinstance(e, HTTPException): raise e
|
39
|
+
elif isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
|
40
|
+
elif isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
|
41
|
+
elif isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
|
42
|
+
elif isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
|
43
|
+
elif isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
|
44
|
+
else: raise HTTPException(status_code=500, detail=str(e))
|
45
|
+
return wrapper
|
46
|
+
|
47
|
+
async def get_credential_from_params(request: Request):
|
48
|
+
return request.query_params.get("token")
|
49
|
+
async def get_current_user(
|
50
|
+
token: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)),
|
51
|
+
q_token: Optional[str] = Depends(get_credential_from_params)
|
52
|
+
):
|
53
|
+
"""
|
54
|
+
First try to get the user from the bearer token,
|
55
|
+
if not found, try to get the user from the query parameter
|
56
|
+
"""
|
57
|
+
if token:
|
58
|
+
user = await conn.user.get_user_by_credential(token.credentials)
|
59
|
+
else:
|
60
|
+
if not q_token:
|
61
|
+
return DECOY_USER
|
62
|
+
else:
|
63
|
+
user = await conn.user.get_user_by_credential(q_token)
|
64
|
+
|
32
65
|
if not user:
|
33
66
|
raise HTTPException(status_code=401, detail="Invalid token")
|
34
67
|
return user
|
35
68
|
|
36
|
-
app = FastAPI(docs_url=
|
69
|
+
app = FastAPI(docs_url="/_docs", redoc_url=None, lifespan=lifespan)
|
37
70
|
app.add_middleware(
|
38
71
|
CORSMiddleware,
|
39
72
|
allow_origins=["*"],
|
@@ -47,6 +80,8 @@ router_fs = APIRouter(prefix="")
|
|
47
80
|
@router_fs.get("/{path:path}")
|
48
81
|
async def get_file(path: str, asfile = False, user: DBUserRecord = Depends(get_current_user)):
|
49
82
|
path = ensure_uri_compnents(path)
|
83
|
+
|
84
|
+
# handle directory query
|
50
85
|
if path == "": path = "/"
|
51
86
|
if path.endswith("/"):
|
52
87
|
# return file under the path as json
|
@@ -67,23 +102,16 @@ async def get_file(path: str, asfile = False, user: DBUserRecord = Depends(get_c
|
|
67
102
|
"dirs": dirs,
|
68
103
|
"files": files
|
69
104
|
}
|
70
|
-
|
105
|
+
|
71
106
|
file_record = await conn.file.get_file_record(path)
|
72
107
|
if not file_record:
|
73
108
|
raise HTTPException(status_code=404, detail="File not found")
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
else:
|
81
|
-
assert path.startswith(f"{user.username}/")
|
82
|
-
elif perm == FileReadPermission.PROTECTED:
|
83
|
-
if user.id == 0:
|
84
|
-
raise HTTPException(status_code=403, detail="Permission denied")
|
85
|
-
else:
|
86
|
-
assert perm == FileReadPermission.PUBLIC
|
109
|
+
|
110
|
+
owner = await conn.user.get_user_by_id(file_record.owner_id)
|
111
|
+
assert owner is not None, "Owner not found"
|
112
|
+
allow_access, reason = check_user_permission(user, owner, file_record)
|
113
|
+
if not allow_access:
|
114
|
+
raise HTTPException(status_code=403, detail=reason)
|
87
115
|
|
88
116
|
fname = path.split("/")[-1]
|
89
117
|
async def send(media_type: Optional[str] = None, disposition = "attachment"):
|
@@ -110,7 +138,7 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
|
|
110
138
|
path = ensure_uri_compnents(path)
|
111
139
|
if user.id == 0:
|
112
140
|
logger.debug("Reject put request from DECOY_USER")
|
113
|
-
raise HTTPException(status_code=
|
141
|
+
raise HTTPException(status_code=401, detail="Permission denied")
|
114
142
|
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
115
143
|
logger.debug(f"Reject put request from {user.username} to {path}")
|
116
144
|
raise HTTPException(status_code=403, detail="Permission denied")
|
@@ -128,20 +156,20 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
|
|
128
156
|
logger.debug(f"Content-Type: {content_type}")
|
129
157
|
if content_type == "application/json":
|
130
158
|
body = await request.json()
|
131
|
-
await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'))
|
159
|
+
await handle_exception(conn.save_file)(user.id, path, json.dumps(body).encode('utf-8'))
|
132
160
|
elif content_type == "application/x-www-form-urlencoded":
|
133
161
|
# may not work...
|
134
162
|
body = await request.form()
|
135
163
|
file = body.get("file")
|
136
164
|
if isinstance(file, str) or file is None:
|
137
165
|
raise HTTPException(status_code=400, detail="Invalid form data, file required")
|
138
|
-
await conn.save_file(user.id, path, await file.read())
|
166
|
+
await handle_exception(conn.save_file)(user.id, path, await file.read())
|
139
167
|
elif content_type == "application/octet-stream":
|
140
168
|
body = await request.body()
|
141
|
-
await conn.save_file(user.id, path, body)
|
169
|
+
await handle_exception(conn.save_file)(user.id, path, body)
|
142
170
|
else:
|
143
171
|
body = await request.body()
|
144
|
-
await conn.save_file(user.id, path, body)
|
172
|
+
await handle_exception(conn.save_file)(user.id, path, body)
|
145
173
|
|
146
174
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
|
147
175
|
if exists_flag:
|
@@ -157,7 +185,7 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
|
|
157
185
|
async def delete_file(path: str, user: DBUserRecord = Depends(get_current_user)):
|
158
186
|
path = ensure_uri_compnents(path)
|
159
187
|
if user.id == 0:
|
160
|
-
raise HTTPException(status_code=
|
188
|
+
raise HTTPException(status_code=401, detail="Permission denied")
|
161
189
|
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
162
190
|
raise HTTPException(status_code=403, detail="Permission denied")
|
163
191
|
|
@@ -184,12 +212,24 @@ async def bundle_files(path: str, user: DBUserRecord = Depends(get_current_user)
|
|
184
212
|
if not path == "" and path[0] == "/": # adapt to both /path and path
|
185
213
|
path = path[1:]
|
186
214
|
|
187
|
-
#
|
188
|
-
|
189
|
-
|
215
|
+
owner_records_cache = {} # cache owner records, ID -> UserRecord
|
216
|
+
async def is_access_granted(file_record: FileDBRecord):
|
217
|
+
owner_id = file_record.owner_id
|
218
|
+
owner = owner_records_cache.get(owner_id, None)
|
219
|
+
if owner is None:
|
220
|
+
owner = await conn.user.get_user_by_id(owner_id)
|
221
|
+
assert owner is not None, f"File owner not found: id={owner_id}"
|
222
|
+
owner_records_cache[owner_id] = owner
|
223
|
+
|
224
|
+
allow_access, _ = check_user_permission(user, owner, file_record)
|
225
|
+
return allow_access
|
226
|
+
|
190
227
|
files = await conn.file.list_path(path, flat = True)
|
228
|
+
files = [f for f in files if await is_access_granted(f)]
|
191
229
|
if len(files) == 0:
|
192
230
|
raise HTTPException(status_code=404, detail="No files found")
|
231
|
+
|
232
|
+
# return bundle of files
|
193
233
|
total_size = sum([f.file_size for f in files])
|
194
234
|
if total_size > MAX_BUNDLE_BYTES:
|
195
235
|
raise HTTPException(status_code=400, detail="Too large to zip")
|
@@ -214,6 +254,47 @@ async def get_file_meta(path: str, user: DBUserRecord = Depends(get_current_user
|
|
214
254
|
raise HTTPException(status_code=404, detail="File not found")
|
215
255
|
return file_record
|
216
256
|
|
257
|
+
@router_api.post("/fmeta")
|
258
|
+
async def update_file_meta(
|
259
|
+
path: str,
|
260
|
+
perm: Optional[int] = None,
|
261
|
+
new_path: Optional[str] = None,
|
262
|
+
user: DBUserRecord = Depends(get_current_user)
|
263
|
+
):
|
264
|
+
if user.id == 0:
|
265
|
+
raise HTTPException(status_code=401, detail="Permission denied")
|
266
|
+
path = ensure_uri_compnents(path)
|
267
|
+
file_record = await conn.file.get_file_record(path)
|
268
|
+
if not file_record:
|
269
|
+
logger.debug(f"Reject update meta request from {user.username} to {path}")
|
270
|
+
raise HTTPException(status_code=404, detail="File not found")
|
271
|
+
|
272
|
+
if not (user.is_admin or user.id == file_record.owner_id):
|
273
|
+
logger.debug(f"Reject update meta request from {user.username} to {path}")
|
274
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
275
|
+
|
276
|
+
if perm is not None:
|
277
|
+
logger.info(f"Update permission of {path} to {perm}")
|
278
|
+
await handle_exception(conn.file.set_file_record)(
|
279
|
+
url = file_record.url,
|
280
|
+
permission = FileReadPermission(perm)
|
281
|
+
)
|
282
|
+
|
283
|
+
if new_path is not None:
|
284
|
+
new_path = ensure_uri_compnents(new_path)
|
285
|
+
logger.info(f"Update path of {path} to {new_path}")
|
286
|
+
await handle_exception(conn.move_file)(path, new_path)
|
287
|
+
|
288
|
+
return Response(status_code=200, content="OK")
|
289
|
+
|
290
|
+
@router_api.get("/whoami")
|
291
|
+
async def whoami(user: DBUserRecord = Depends(get_current_user)):
|
292
|
+
if user.id == 0:
|
293
|
+
raise HTTPException(status_code=401, detail="Login required")
|
294
|
+
user.credential = "__HIDDEN__"
|
295
|
+
return user
|
296
|
+
|
297
|
+
|
217
298
|
# order matters
|
218
299
|
app.include_router(router_api)
|
219
300
|
app.include_router(router_fs)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.3
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li, mengxun
|
@@ -18,6 +18,7 @@ Project-URL: Repository, https://github.com/MenxLi/lfss
|
|
18
18
|
Description-Content-Type: text/markdown
|
19
19
|
|
20
20
|
# Lightweight File Storage Service (LFSS)
|
21
|
+
[](https://pypi.org/project/lfss/)
|
21
22
|
|
22
23
|
A lightweight file/object storage service!
|
23
24
|
|
@@ -28,15 +29,19 @@ lfss-user add <username> <password>
|
|
28
29
|
lfss-serve
|
29
30
|
```
|
30
31
|
|
31
|
-
By default, the data will be stored in
|
32
|
-
|
32
|
+
By default, the data will be stored in `.storage_data`.
|
33
|
+
You can change storage directory using the `LFSS_DATA` environment variable.
|
33
34
|
|
34
35
|
I provide a simple client to interact with the service.
|
35
|
-
Just start a web server at `/frontend` and open `index.html` in your browser
|
36
|
-
|
37
|
-
|
38
|
-
|
36
|
+
Just start a web server at `/frontend` and open `index.html` in your browser, or use:
|
37
|
+
```sh
|
38
|
+
lfss-panel
|
39
|
+
```
|
39
40
|
|
40
41
|
The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
|
41
42
|
Authentication is done via `Authorization` header, with the value `Bearer <token>`.
|
42
|
-
|
43
|
+
You can refer to `frontend` as an application example, and `frontend/api.js` for the API usage.
|
44
|
+
|
45
|
+
By default, the service exposes all files to the public for `GET` requests,
|
46
|
+
but file-listing is restricted to the user's own files.
|
47
|
+
Please refer to [docs/Permission.md](./docs/Permission.md) for more details on the permission system.
|
@@ -0,0 +1,24 @@
|
|
1
|
+
Readme.md,sha256=HJTfAkTka7i9n8JaA_Sftn1RFOplCviJ16Rq4WsDOFc,1056
|
2
|
+
docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
|
3
|
+
docs/Permission.md,sha256=EY1Y4tT5PDdHDb2pXsVgMAJXxkUihTZfPT0_z7Q53FA,1485
|
4
|
+
frontend/api.js,sha256=D_MaQlmBGzzSR30a23Kh3DxPeF8MpKYe5uXSb9srRTU,7281
|
5
|
+
frontend/index.html,sha256=2IOrJge3HmQLldKeqAI5Eqz2kkVr8NYlS434_9PwZ0I,2019
|
6
|
+
frontend/popup.css,sha256=nbCHFZwt8XrCOODkxuYmLd14nKHAZsOZ5JLqUI6iTDQ,592
|
7
|
+
frontend/popup.js,sha256=y4P1gaF05HwmhQZN-NRFYNbQ3zKBdoTXtTJpT3FTDQQ,2918
|
8
|
+
frontend/scripts.js,sha256=L9lWhhprdAJOWSOCX9BywZzljm0yJ5PET5AaheCAGqg,16824
|
9
|
+
frontend/styles.css,sha256=1klxag-IvfHm316Tj8CFHqJmhSCA3E6Pcdtj51mJMiY,3922
|
10
|
+
frontend/utils.js,sha256=biE2te5ezswZyuwlDTYbHEt7VWKfCcUrusNt1lHjkLw,2263
|
11
|
+
lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
|
12
|
+
lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
|
13
|
+
lfss/cli/user.py,sha256=906MIQO1mr4XXzPpT8Kfri1G61bWlgjDzIglxypQ7z0,3251
|
14
|
+
lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
15
|
+
lfss/src/config.py,sha256=rE2ZCpozSDqdFrVvpItfGb_O3wPh_ErotdLTZ1DA23s,280
|
16
|
+
lfss/src/database.py,sha256=2ZgQGd6tDR6daXl3jOOBO1-mHEwNd809m16PopQ8DZg,27535
|
17
|
+
lfss/src/error.py,sha256=S5ui3tJ0uKX4EZnt2Db-KbDmJXlECI_OY95cNMkuegc,218
|
18
|
+
lfss/src/log.py,sha256=7mRHFwhx7GKtm_cRryoEIlRQhHTLQC3Qd-N81YsoKao,5174
|
19
|
+
lfss/src/server.py,sha256=7fh2FLyyFeTgjgeM4GRtddVKUTb1qgzU0So0SOLcGB0,11488
|
20
|
+
lfss/src/utils.py,sha256=MrjKc8W2Y7AbgVGadSNAA50tRMbGYWRrA4KUhOCwuUU,694
|
21
|
+
lfss-0.2.3.dist-info/METADATA,sha256=YUcuXDq-7lTNFcMwMsxbp3uP3TcrjtDLBRUtDiackQQ,1715
|
22
|
+
lfss-0.2.3.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
23
|
+
lfss-0.2.3.dist-info/entry_points.txt,sha256=nUIJhenyZbcymvPVhrzV7SAtRhd7O52DflbRrpQUC04,110
|
24
|
+
lfss-0.2.3.dist-info/RECORD,,
|
lfss-0.1.0.dist-info/RECORD
DELETED
@@ -1,12 +0,0 @@
|
|
1
|
-
lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
|
2
|
-
lfss/cli/user.py,sha256=asf5NTDoRJdBos_TnM0olzbpUZ3P3OXbW3eQmZkMnBg,2542
|
3
|
-
lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
-
lfss/src/config.py,sha256=rE2ZCpozSDqdFrVvpItfGb_O3wPh_ErotdLTZ1DA23s,280
|
5
|
-
lfss/src/database.py,sha256=5ePxvTB4vDQjiwTaTLkzXI_ld3jrjSrJyaHgG7aPIkk,21054
|
6
|
-
lfss/src/log.py,sha256=7mRHFwhx7GKtm_cRryoEIlRQhHTLQC3Qd-N81YsoKao,5174
|
7
|
-
lfss/src/server.py,sha256=YsHPXl7O29EmE5adthL6wta7haXj_qL3LdZMqFyi4rc,8090
|
8
|
-
lfss/src/utils.py,sha256=MrjKc8W2Y7AbgVGadSNAA50tRMbGYWRrA4KUhOCwuUU,694
|
9
|
-
lfss-0.1.0.dist-info/METADATA,sha256=-p8KgLhqqlaX38vdIa-m1aWfNfqVT-3KMI5ICKjqDtM,1604
|
10
|
-
lfss-0.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
11
|
-
lfss-0.1.0.dist-info/entry_points.txt,sha256=4OqcJpLk9bSW01R7AUeaE4j2CZkuNsgq1iZOYpkwxEA,79
|
12
|
-
lfss-0.1.0.dist-info/RECORD,,
|
File without changes
|