lfss 0.2.4__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.2.4
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, 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.
@@ -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
- <input type="text" id="file-name" placeholder="Destination file path" autocomplete="off">
52
- <span id='randomize-fname-btn'>🎲</span>
53
- <button id="upload-btn">Upload</button>
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
 
@@ -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?path=' + dir.url + (dir.url.endsWith('/') ? '' : '/');
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 + '?asfile=true&token=' + conn.config.token;
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');
@@ -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())
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
@@ -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 DBUserRecord:
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 = DBUserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
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) -> DBUserRecord:
82
- return DBUserRecord(*record)
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[DBUserRecord]:
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[DBUserRecord]:
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[DBUserRecord]:
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 FileDBRecord:
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) -> FileDBRecord:
211
- return FileDBRecord(*record)
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.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
 
258
- async def get_file_record(self, url: str) -> Optional[FileDBRecord]:
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[FileDBRecord]:
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[FileDBRecord]:
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[FileDBRecord]:
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[FileDBRecord]:...
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]) -> 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[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 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
 
@@ -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[DBUserRecord]:
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[FileDBRecord]:
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 zip_path(self, top_url: str, urls: Optional[list[str]]) -> io.BytesIO:
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 url in urls:
635
- if not url.startswith(top_url):
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: DBUserRecord, owner: DBUserRecord, file: FileDBRecord) -> tuple[bool, str]:
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
 
@@ -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, DBUserRecord, DECOY_USER, FileDBRecord, check_user_permission, FileReadPermission
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, asfile = False, user: DBUserRecord = Depends(get_current_user)):
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
- 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,13 +124,13 @@ 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")
135
131
 
136
132
  @router_fs.put("/{path:path}")
137
- async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get_current_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: DBUserRecord = Depends(get_current_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: DBUserRecord = Depends(get_current_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: FileDBRecord):
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: DBUserRecord = Depends(get_current_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: DBUserRecord = Depends(get_current_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: DBUserRecord = Depends(get_current_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
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.2.4"
3
+ version = "0.3.1"
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