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.
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
- async def get_current_user(token: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
29
- if not token:
30
- return DECOY_USER
31
- user = await conn.user.get_user_by_credential(token.credentials)
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=None, redoc_url=None, lifespan=lifespan)
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
- # permission check
76
- perm = file_record.permission
77
- if perm == FileReadPermission.PRIVATE:
78
- if not user.is_admin and user.id != file_record.owner_id:
79
- raise HTTPException(status_code=403, detail="Permission denied")
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=403, detail="Permission denied")
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=403, detail="Permission denied")
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
- # TODO: maybe check permission here...
188
-
189
- # return bundle of files
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.1.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
+ [![PyPI](https://img.shields.io/pypi/v/lfss)](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 the `.storage_data` directory, in a sqlite database.
32
- The data storage can be set via environment variable `LFSS_DATA`.
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
- Currently, there is no file access-control, anyone can access any file with `GET` request.
38
- However, the path-listing is only available to the authenticated user (to their own files, under `<username>/`).
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
- Please refer to `frontend` as an application example, and `frontend/api.js` for the API usage.
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,,
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ lfss-panel=lfss.cli.panel:main
2
3
  lfss-serve=lfss.cli.serve:main
3
4
  lfss-user=lfss.cli.user:main
4
5
 
@@ -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