lfss 0.2.4__tar.gz → 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.2.4
3
+ Version: 0.3.0
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, with the value `Bearer <token>`.
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.
@@ -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, with the value `Bearer <token>`.
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.
@@ -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
  });
@@ -401,7 +404,7 @@ function refreshFileList(){
401
404
 
402
405
  const downloadBtn = document.createElement('a');
403
406
  downloadBtn.textContent = 'Download';
404
- downloadBtn.href = conn.config.endpoint + '/' + file.url + '?asfile=true&token=' + conn.config.token;
407
+ downloadBtn.href = conn.config.endpoint + '/' + file.url + '?download=true&token=' + conn.config.token;
405
408
  actContainer.appendChild(downloadBtn);
406
409
 
407
410
  const deleteButton = document.createElement('a');
@@ -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, FileDBRecord, DBUserRecord, 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[FileDBRecord]:
62
+ """Gets the metadata for the file at the specified path."""
63
+ try:
64
+ response = self._fetch('GET', '_api/fmeta', {'path': path})()
65
+ return FileDBRecord(**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) -> DBUserRecord:
89
+ """Gets information about the current user."""
90
+ response = self._fetch('GET', '_api/whoami')()
91
+ return DBUserRecord(**response.json())
File without changes
@@ -8,4 +8,5 @@ if not DATA_HOME.exists():
8
8
  DATA_HOME.mkdir()
9
9
  print(f"[init] Created data home at {DATA_HOME}")
10
10
 
11
- MAX_BUNDLE_BYTES = 128 * 1024 * 1024 # 128MB
11
+ MAX_FILE_BYTES = 256 * 1024 * 1024 # 256MB
12
+ MAX_BUNDLE_BYTES = 256 * 1024 * 1024 # 256MB
@@ -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:
@@ -175,9 +172,11 @@ 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}")
@@ -203,6 +202,11 @@ 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[FileDBRecord]
206
210
 
207
211
  class FileConn(DBConnBase):
208
212
 
@@ -251,7 +255,7 @@ 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.user_size_inc(r[0], size[0])
258
+ await self._user_size_inc(r[0], size[0])
255
259
 
256
260
  return self
257
261
 
@@ -299,9 +303,9 @@ class FileConn(DBConnBase):
299
303
  @overload
300
304
  async def list_path(self, url: str, flat: Literal[True]) -> list[FileDBRecord]:...
301
305
  @overload
302
- async def list_path(self, url: str, flat: Literal[False]) -> tuple[list[DirectoryRecord], list[FileDBRecord]]:...
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[FileDBRecord] | tuple[list[DirectoryRecord], list[FileDBRecord]]:
308
+ async def list_path(self, url: str, flat: bool = False) -> list[FileDBRecord] | 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 user_size_inc(self, user_id: int, inc: int):
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 user_size_dec(self, user_id: int, dec: int):
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.user_size_inc(owner_id, file_size)
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.user_size_dec(file_record.owner_id, file_record.file_size)
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.user_size_dec(r[0], size[0])
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
 
@@ -13,7 +13,7 @@ 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
18
  from .database import Database, DBUserRecord, DECOY_USER, FileDBRecord, check_user_permission, FileReadPermission
19
19
 
@@ -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, asfile = False, user: DBUserRecord = Depends(get_current_user)):
81
+ async def get_file(path: str, download = False, user: DBUserRecord = 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
- dirs, files = await conn.file.list_path(path, flat = False)
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,7 +124,7 @@ async def get_file(path: str, asfile = False, user: DBUserRecord = Depends(get_c
128
124
  }
129
125
  )
130
126
 
131
- if asfile:
127
+ if download:
132
128
  return await send('application/octet-stream', "attachment")
133
129
  else:
134
130
  return await send(None, "inline")
@@ -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)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.2.4"
3
+ version = "0.3.0"
4
4
  description = "Lightweight file storage service"
5
5
  authors = ["li, mengxun <limengxun45@outlook.com>"]
6
6
  readme = "Readme.md"
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