lfss 0.2.3__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.3
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.
@@ -13,7 +13,7 @@
13
13
  <label for="endpoint">Endpoint</label>
14
14
  <input type="text" id="endpoint" placeholder="http://localhost:8000" autocomplete="off">
15
15
  </div>
16
- <div class="input-group" style="min-width: 800px;">
16
+ <div class="input-group" style="min-width: 300px;">
17
17
  <label for="token">Token</label>
18
18
  <input type="text" id="token" placeholder="" autocomplete="off">
19
19
  </div>
@@ -27,4 +27,27 @@ div.floating-window.window{
27
27
  flex-direction: column;
28
28
  justify-content: center;
29
29
  align-items: center;
30
+ }
31
+
32
+ div.popup-window{
33
+ position: fixed;
34
+ top: 0.5rem;
35
+ right: 1rem;
36
+ border-radius: 0.5rem;
37
+ box-shadow: 0 5px 10px rgba(0,0,0,0.2);
38
+ color: white;
39
+ display: block;
40
+ text-align: left;
41
+ animation: popup-appear 0.5s ease;
42
+ }
43
+
44
+ @keyframes popup-appear{
45
+ from{
46
+ opacity: 0;
47
+ transform: translateX(1rem);
48
+ }
49
+ to{
50
+ opacity: 1;
51
+ transform: translateX(0);
52
+ }
30
53
  }
@@ -86,4 +86,45 @@ export function showFloatingWindowLineInput(onSubmit = (v) => {}, {
86
86
  }
87
87
 
88
88
  return [floatingWindow, closeWindow];
89
+ }
90
+
91
+ const shownPopups = [];
92
+ export function showPopup(content = '', {
93
+ level = "info",
94
+ width = "auto",
95
+ timeout = 3000,
96
+ showTime = true
97
+ } = {}){
98
+ const popup = document.createElement("div");
99
+ popup.classList.add("popup-window");
100
+ popup.innerHTML = showTime? `<span>[${new Date().toLocaleTimeString()}]</span> ${content}` : content;
101
+ popup.style.width = width;
102
+ const popupHeight = '1rem';
103
+ popup.style.height = popupHeight;
104
+ popup.style.maxHeight = popupHeight;
105
+ popup.style.minHeight = popupHeight;
106
+ const paddingHeight = '1rem';
107
+ popup.style.padding = paddingHeight;
108
+
109
+ // traverse shownPopups and update the top position of each popup
110
+ if (shownPopups.length > 0) {
111
+ for (let i = 0; i < shownPopups.length; i++) {
112
+ shownPopups[i].style.top = `${i * (parseInt(popupHeight) + 2*parseInt(paddingHeight))*1.2 + 0.5}rem`;
113
+ }
114
+ }
115
+ popup.style.top = `${shownPopups.length * (parseInt(popupHeight) + 2*parseInt(paddingHeight))*1.2 + 0.5}rem`;
116
+
117
+ if (level === "error") popup.style.backgroundColor = "darkred";
118
+ if (level === "warning") popup.style.backgroundColor = "darkorange";
119
+ if (level === "info") popup.style.backgroundColor = "darkblue";
120
+ if (level === "success") popup.style.backgroundColor = "darkgreen";
121
+ document.body.appendChild(popup);
122
+ shownPopups.push(popup);
123
+ window.setTimeout(() => {
124
+ if (popup.parentNode) document.body.removeChild(popup);
125
+ shownPopups.splice(shownPopups.indexOf(popup), 1);
126
+ for (let i = 0; i < shownPopups.length; i++) {
127
+ shownPopups[i].style.top = `${i * (parseInt(popupHeight) + 2*parseInt(paddingHeight))*1.2 + 0.5}rem`;
128
+ }
129
+ }, timeout);
89
130
  }
@@ -1,6 +1,6 @@
1
1
  import Connector from './api.js';
2
2
  import { permMap } from './api.js';
3
- import { showFloatingWindowLineInput } from './popup.js';
3
+ import { showFloatingWindowLineInput, showPopup } from './popup.js';
4
4
  import { formatSize, decodePathURI, ensurePathURI, copyToClipboard, getRandomString, cvtGMT2Local, debounce, encodePathURI } from './utils.js';
5
5
 
6
6
  const conn = new Connector();
@@ -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
  });
@@ -168,7 +171,12 @@ Are you sure you want to proceed?
168
171
  let counter = 0;
169
172
  async function uploadFile(...args){
170
173
  const [file, path] = args;
171
- await conn.put(path, file);
174
+ try{
175
+ await conn.put(path, file);
176
+ }
177
+ catch (err){
178
+ showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
179
+ }
172
180
  counter += 1;
173
181
  console.log("Uploading file: ", counter, "/", files.length);
174
182
  }
@@ -261,6 +269,7 @@ function refreshFileList(){
261
269
 
262
270
  const deleteButton = document.createElement('a');
263
271
  deleteButton.textContent = 'Delete';
272
+ deleteButton.classList.add('delete-btn');
264
273
  deleteButton.href = '#';
265
274
  deleteButton.addEventListener('click', () => {
266
275
  const dirurl = dir.url + (dir.url.endsWith('/') ? '' : '/');
@@ -270,6 +279,8 @@ function refreshFileList(){
270
279
  conn.delete(dirurl)
271
280
  .then(() => {
272
281
  refreshFileList();
282
+ }, (err)=>{
283
+ showPopup('Failed to delete path: ' + err, {level: 'error', timeout: 5000});
273
284
  });
274
285
  });
275
286
  actContainer.appendChild(deleteButton);
@@ -332,7 +343,12 @@ function refreshFileList(){
332
343
  console.warn("Permission string mismatch", permStr, permStrFromMap);
333
344
  }
334
345
  }
335
- conn.setFilePermission(file.url, perm)
346
+ conn.setFilePermission(file.url, perm).then(
347
+ () => {},
348
+ (err) => {
349
+ showPopup('Failed to set permission: ' + err, {level: 'error', timeout: 5000});
350
+ }
351
+ );
336
352
  });
337
353
 
338
354
  accessTd.appendChild(select);
@@ -345,23 +361,24 @@ function refreshFileList(){
345
361
  const actContainer = document.createElement('div');
346
362
  actContainer.classList.add('action-container');
347
363
 
348
- const viewButton = document.createElement('a');
349
- viewButton.textContent = 'View';
350
- viewButton.href = conn.config.endpoint + '/' + file.url + '?token=' + conn.config.token;
351
- viewButton.target = '_blank';
352
- actContainer.appendChild(viewButton);
353
-
354
364
  const copyButton = document.createElement('a');
365
+ copyButton.style.cursor = 'pointer';
355
366
  copyButton.textContent = 'Share';
356
- copyButton.href = '#';
357
367
  copyButton.addEventListener('click', () => {
358
368
  copyToClipboard(conn.config.endpoint + '/' + file.url);
369
+ showPopup('Link copied to clipboard', {level: "success"});
359
370
  });
360
371
  actContainer.appendChild(copyButton);
361
372
 
373
+ const viewButton = document.createElement('a');
374
+ viewButton.textContent = 'View';
375
+ viewButton.href = conn.config.endpoint + '/' + file.url + '?token=' + conn.config.token;
376
+ viewButton.target = '_blank';
377
+ actContainer.appendChild(viewButton);
378
+
362
379
  const moveButton = document.createElement('a');
363
380
  moveButton.textContent = 'Move';
364
- moveButton.href = '#';
381
+ moveButton.style.cursor = 'pointer';
365
382
  moveButton.addEventListener('click', () => {
366
383
  showFloatingWindowLineInput((dstPath) => {
367
384
  dstPath = encodePathURI(dstPath);
@@ -371,7 +388,11 @@ function refreshFileList(){
371
388
  conn.moveFile(file.url, dstPath)
372
389
  .then(() => {
373
390
  refreshFileList();
374
- });
391
+ },
392
+ (err) => {
393
+ showPopup('Failed to move file: ' + err, {level: 'error'});
394
+ }
395
+ );
375
396
  }, {
376
397
  text: 'Enter the destination path: ',
377
398
  placeholder: 'Destination path',
@@ -383,11 +404,12 @@ function refreshFileList(){
383
404
 
384
405
  const downloadBtn = document.createElement('a');
385
406
  downloadBtn.textContent = 'Download';
386
- 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;
387
408
  actContainer.appendChild(downloadBtn);
388
409
 
389
410
  const deleteButton = document.createElement('a');
390
411
  deleteButton.textContent = 'Delete';
412
+ deleteButton.classList.add('delete-btn');
391
413
  deleteButton.href = '#';
392
414
  deleteButton.addEventListener('click', () => {
393
415
  if (!confirm('Are you sure you want to delete ' + file.url + '?')){
@@ -396,6 +418,8 @@ function refreshFileList(){
396
418
  conn.delete(file.url)
397
419
  .then(() => {
398
420
  refreshFileList();
421
+ }, (err) => {
422
+ showPopup('Failed to delete file: ' + err, {level: 'error', timeout: 5000});
399
423
  });
400
424
  });
401
425
  actContainer.appendChild(deleteButton);
@@ -14,7 +14,7 @@ input[type=button], button{
14
14
  padding: 0.8rem;
15
15
  margin: 0;
16
16
  border: none;
17
- border-radius: 0.2rem;
17
+ border-radius: 0.25rem;
18
18
  cursor: pointer;
19
19
  }
20
20
 
@@ -23,7 +23,7 @@ input[type=text], input[type=password]
23
23
  width: 100%;
24
24
  padding: 0.75rem;
25
25
  border: 1px solid #ccc;
26
- border-radius: 0.2rem;
26
+ border-radius: 0.25rem;
27
27
  height: 1rem;
28
28
  }
29
29
 
@@ -209,4 +209,12 @@ a{
209
209
  background-color: #195f8b;
210
210
  transform: scale(1.1);
211
211
  color: white;
212
+ }
213
+
214
+ .delete-btn{
215
+ color: darkred !important;
216
+ }
217
+ .delete-btn:hover{
218
+ color: white !important;
219
+ background-color: #990511c7 !important;
212
220
  }
@@ -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)
@@ -206,6 +209,8 @@ router_api = APIRouter(prefix="/_api")
206
209
  @router_api.get("/bundle")
207
210
  async def bundle_files(path: str, user: DBUserRecord = Depends(get_current_user)):
208
211
  logger.info(f"GET bundle({path}), user: {user.username}")
212
+ if user.id == 0:
213
+ raise HTTPException(status_code=401, detail="Permission denied")
209
214
  path = ensure_uri_compnents(path)
210
215
  assert path.endswith("/") or path == ""
211
216
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.2.3"
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