lfss 0.8.4__py3-none-any.whl → 0.9.0__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.
docs/Permission.md CHANGED
@@ -1,12 +1,25 @@
1
1
 
2
2
  # Permission System
3
- There are two user roles in the system: Admin and Normal User ("users" are like "buckets" to some extent).
3
+ There are two user roles in the system: Admin and Normal User ("users" are like "buckets" to some extent).
4
+ A user have all permissions of the files and subpaths under its path (starting with `/<user>/`).
5
+ Admins have all permissions of all files and paths.
6
+
7
+ > **path** ends with `/` and **file** does not end with `/`.
8
+
9
+ ## Peers
10
+ The user can have multiple peer users. The peer user can have read or write access to the user's path, depending on the access level set when adding the peer user.
11
+ The peer user can list the files under the user's path.
12
+ If the peer user only has read access (peer-r), then the peer user can only `GET` files under the user's path.
13
+ If the peer user has write access (peer-w), then the peer user can `GET`/`PUT`/`POST`/`DELETE` files under the user's path.
4
14
 
5
15
  ## Ownership
6
- A file is owned by the user who created it, may not necessarily be the user under whose path the file is stored (admin can create files under any user's path).
16
+ A file is owned by the user who created it, may not necessarily be the user under whose path the file is stored (admin/write-peer can create files under any user's path).
17
+
18
+ # Non-peer and public access
19
+
20
+ **NOTE:** below discussion is based on the assumption that the user is not a peer of the path owner, or is guest user (public access).
7
21
 
8
22
  ## File access with `GET` permission
9
- The `GET` is used to access the file (if path is not ending with `/`), or to list the files under a path (if path is ending with `/`).
10
23
 
11
24
  ### File access
12
25
  For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the owner and the file.
@@ -20,27 +33,26 @@ Non-admin users can access files based on:
20
33
  - If the file is `unset`, then the file's permission is inherited from the owner's permission.
21
34
  - If both the owner and the file have `unset` permission, then the file is `public`.
22
35
 
23
- ### Meta-data access
24
- - Non-login users can't access any file-meta.
25
- - All users can access the file-meta of files under their own path.
26
- - For files under other users' path, the file-meta is determined in a way same as file access.
27
- - Admins can access the path-meta of all users.
28
- - All users can access the path-meta of their own path.
29
-
30
- ### Path-listing
31
- - Non-login users cannot list any files.
32
- - All users can list the files under their own path
33
- - Admins can list the files under other users' path.
34
-
35
- ## File creation with `PUT` permission
36
- The `PUT` is used to create a file.
37
- - Non-login user don't have `PUT` permission.
38
- - Every user can have `PUT` permission of files under its own `/<user>/` path.
39
- - The admin can have `PUT` permission of files of all users.
40
-
41
- ## `DELETE` and moving permissions
36
+ ## File creation with `PUT`/`POST` permission
37
+ `PUT`/`POST` permission is not allowed for non-peer users.
38
+
39
+ ## File `DELETE` and moving permissions
42
40
  - Non-login user don't have `DELETE`/move permission.
43
- - Every user can have `DELETE`/move permission that they own.
44
- - The admin can have `DELETE` permission of files of all users
45
- (The admin can't move files of other users, because move does not change the owner of the file.
46
- If move is allowed, then its equivalent to create file on behalf of other users.)
41
+ - Every user can have `DELETE` permission that they own.
42
+ - User can move files if they have write access to the destination path.
43
+
44
+ ## Path-listing
45
+ Path-listing is not allowed for these users.
46
+
47
+ # Summary
48
+
49
+ | Permission | Admin | User | Peer-r | Peer-w | Owner (not the user) | Non-peer user / Guest |
50
+ |------------|-------|------|--------|--------|----------------------|------------------------|
51
+ | GET | Yes | Yes | Yes | Yes | Yes | Depends on file |
52
+ | PUT/POST | Yes | Yes | No | Yes | Yes | No |
53
+ | DELETE file| Yes | Yes | No | Yes | Yes | No |
54
+ | DELETE path| Yes | Yes | No | Yes | N/A | No |
55
+ | move | Yes | Yes | No | Yes | Dep. on destination | No |
56
+ | list | Yes | Yes | Yes | Yes | No if not peer | No |
57
+
58
+ > Capitilized methods are HTTP methods, N/A means not applicable.
frontend/api.js CHANGED
@@ -45,6 +45,17 @@ export const permMap = {
45
45
  3: 'private'
46
46
  }
47
47
 
48
+ async function fmtFailedResponse(res){
49
+ const raw = await res.text();
50
+ const json = raw ? JSON.parse(raw) : {};
51
+ const txt = JSON.stringify(json.detail || json || "No message");
52
+ const maxWords = 32;
53
+ if (txt.length > maxWords){
54
+ return txt.slice(0, maxWords) + '...';
55
+ }
56
+ return txt;
57
+ }
58
+
48
59
  export default class Connector {
49
60
 
50
61
  constructor(){
@@ -79,7 +90,7 @@ export default class Connector {
79
90
  body: fileBytes
80
91
  });
81
92
  if (res.status != 200 && res.status != 201){
82
- throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await res.json()}`);
93
+ throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
83
94
  }
84
95
  return (await res.json()).url;
85
96
  }
@@ -111,7 +122,7 @@ export default class Connector {
111
122
  });
112
123
 
113
124
  if (res.status != 200 && res.status != 201){
114
- throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await res.json()}`);
125
+ throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
115
126
  }
116
127
  return (await res.json()).url;
117
128
  }
@@ -133,7 +144,7 @@ export default class Connector {
133
144
  body: JSON.stringify(data)
134
145
  });
135
146
  if (res.status != 200 && res.status != 201){
136
- throw new Error(`Failed to upload object, status code: ${res.status}, message: ${await res.json()}`);
147
+ throw new Error(`Failed to upload object, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
137
148
  }
138
149
  return (await res.json()).url;
139
150
  }
@@ -147,7 +158,7 @@ export default class Connector {
147
158
  },
148
159
  });
149
160
  if (res.status == 200) return;
150
- throw new Error(`Failed to delete file, status code: ${res.status}, message: ${await res.json()}`);
161
+ throw new Error(`Failed to delete file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
151
162
  }
152
163
 
153
164
  /**
@@ -211,7 +222,7 @@ export default class Connector {
211
222
  },
212
223
  });
213
224
  if (res.status != 200){
214
- throw new Error(`Failed to count files, status code: ${res.status}, message: ${await res.json()}`);
225
+ throw new Error(`Failed to count files, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
215
226
  }
216
227
  return (await res.json()).count;
217
228
  }
@@ -250,7 +261,7 @@ export default class Connector {
250
261
  },
251
262
  });
252
263
  if (res.status != 200){
253
- throw new Error(`Failed to list files, status code: ${res.status}, message: ${await res.json()}`);
264
+ throw new Error(`Failed to list files, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
254
265
  }
255
266
  return await res.json();
256
267
  }
@@ -270,7 +281,7 @@ export default class Connector {
270
281
  },
271
282
  });
272
283
  if (res.status != 200){
273
- throw new Error(`Failed to count directories, status code: ${res.status}, message: ${await res.json()}`);
284
+ throw new Error(`Failed to count directories, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
274
285
  }
275
286
  return (await res.json()).count;
276
287
  }
@@ -309,7 +320,7 @@ export default class Connector {
309
320
  },
310
321
  });
311
322
  if (res.status != 200){
312
- throw new Error(`Failed to list directories, status code: ${res.status}, message: ${await res.json()}`);
323
+ throw new Error(`Failed to list directories, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
313
324
  }
314
325
  return await res.json();
315
326
  }
@@ -347,7 +358,7 @@ export default class Connector {
347
358
  },
348
359
  });
349
360
  if (res.status != 200){
350
- throw new Error(`Failed to set permission, status code: ${res.status}, message: ${await res.json()}`);
361
+ throw new Error(`Failed to set permission, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
351
362
  }
352
363
  }
353
364
 
@@ -369,7 +380,7 @@ export default class Connector {
369
380
  },
370
381
  });
371
382
  if (res.status != 200){
372
- throw new Error(`Failed to move file, status code: ${res.status}, message: ${await res.json()}`);
383
+ throw new Error(`Failed to move file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
373
384
  }
374
385
  }
375
386
 
lfss/api/connector.py CHANGED
@@ -17,14 +17,20 @@ _default_token = os.environ.get('LFSS_TOKEN', '')
17
17
 
18
18
  class Connector:
19
19
  class Session:
20
- def __init__(self, connector: Connector, pool_size: int = 10):
20
+ def __init__(
21
+ self, connector: Connector, pool_size: int = 10,
22
+ retry: int = 1, backoff_factor: float = 0.5, status_forcelist: list[int] = [503]
23
+ ):
21
24
  self.connector = connector
22
25
  self.pool_size = pool_size
26
+ self.retry_adapter = requests.adapters.Retry(
27
+ total=retry, backoff_factor=backoff_factor, status_forcelist=status_forcelist,
28
+ )
23
29
  def open(self):
24
30
  self.close()
25
31
  if self.connector._session is None:
26
32
  s = requests.Session()
27
- adapter = requests.adapters.HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size)
33
+ adapter = requests.adapters.HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size, max_retries=self.retry_adapter)
28
34
  s.mount('http://', adapter)
29
35
  s.mount('https://', adapter)
30
36
  self.connector._session = s
@@ -48,9 +54,9 @@ class Connector:
48
54
  }
49
55
  self._session: Optional[requests.Session] = None
50
56
 
51
- def session(self, pool_size: int = 10):
57
+ def session( self, pool_size: int = 10, **kwargs):
52
58
  """ avoid creating a new session for each request. """
53
- return self.Session(self, pool_size)
59
+ return self.Session(self, pool_size, **kwargs)
54
60
 
55
61
  def _fetch_factory(
56
62
  self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
lfss/cli/cli.py CHANGED
@@ -6,14 +6,9 @@ from lfss.src.utils import decode_uri_compnents
6
6
  from . import catch_request_error, line_sep
7
7
 
8
8
  def parse_permission(s: str) -> FileReadPermission:
9
- if s.lower() == "public":
10
- return FileReadPermission.PUBLIC
11
- if s.lower() == "protected":
12
- return FileReadPermission.PROTECTED
13
- if s.lower() == "private":
14
- return FileReadPermission.PRIVATE
15
- if s.lower() == "unset":
16
- return FileReadPermission.UNSET
9
+ for p in FileReadPermission:
10
+ if p.name.lower() == s.lower():
11
+ return p
17
12
  raise ValueError(f"Invalid permission {s}")
18
13
 
19
14
  def parse_arguments():
lfss/cli/user.py CHANGED
@@ -2,9 +2,16 @@ import argparse, asyncio, os
2
2
  from contextlib import asynccontextmanager
3
3
  from .cli import parse_permission, FileReadPermission
4
4
  from ..src.utils import parse_storage_size, fmt_storage_size
5
+ from ..src.datatype import AccessLevel
5
6
  from ..src.database import Database, FileReadPermission, transaction, UserConn, unique_cursor, FileConn
6
7
  from ..src.connection_pool import global_entrance
7
8
 
9
+ def parse_access_level(s: str) -> AccessLevel:
10
+ for p in AccessLevel:
11
+ if p.name.lower() == s.lower():
12
+ return p
13
+ raise ValueError(f"Invalid access level {s}")
14
+
8
15
  @global_entrance(1)
9
16
  async def _main():
10
17
  parser = argparse.ArgumentParser()
@@ -31,11 +38,16 @@ async def _main():
31
38
  sp_set.add_argument('-a', '--admin', type=parse_bool, default=None)
32
39
  sp_set.add_argument('--permission', type=parse_permission, default=None)
33
40
  sp_set.add_argument('--max-storage', type=parse_storage_size, default=None)
34
-
41
+
35
42
  sp_list = sp.add_parser('list')
36
43
  sp_list.add_argument("username", nargs='*', type=str, default=None)
37
44
  sp_list.add_argument("-l", "--long", action="store_true")
38
45
 
46
+ sp_peer = sp.add_parser('set-peer')
47
+ sp_peer.add_argument('src_username', type=str)
48
+ sp_peer.add_argument('dst_username', type=str)
49
+ sp_peer.add_argument('--level', type=parse_access_level, default=AccessLevel.READ, help="Access level")
50
+
39
51
  args = parser.parse_args()
40
52
  db = await Database().init()
41
53
 
@@ -72,6 +84,16 @@ async def _main():
72
84
  assert user is not None
73
85
  print('User updated, credential:', user.credential)
74
86
 
87
+ if args.subparser_name == 'set-peer':
88
+ async with get_uconn() as uconn:
89
+ src_user = await uconn.get_user(args.src_username)
90
+ dst_user = await uconn.get_user(args.dst_username)
91
+ if src_user is None or dst_user is None:
92
+ print('User not found')
93
+ exit(1)
94
+ await uconn.set_peer_level(src_user.id, dst_user.id, args.level)
95
+ print(f"Peer set: [{src_user.username}] now have [{args.level.name}] access to [{dst_user.username}]")
96
+
75
97
  if args.subparser_name == 'list':
76
98
  async with get_uconn() as uconn:
77
99
  term_width = os.get_terminal_size().columns
@@ -86,6 +108,11 @@ async def _main():
86
108
  user_size_used = await fconn.user_size(user.id)
87
109
  print('- Credential: ', user.credential)
88
110
  print(f'- Storage: {fmt_storage_size(user_size_used)} / {fmt_storage_size(user.max_storage)}')
111
+ for p in AccessLevel:
112
+ if p > AccessLevel.NONE:
113
+ usernames = [x.username for x in await uconn.list_peer_users(user.id, p)]
114
+ if usernames:
115
+ print(f'- Peers [{p.name}]: {", ".join(usernames)}')
89
116
 
90
117
  def main():
91
118
  asyncio.run(_main())
lfss/sql/init.sql CHANGED
@@ -27,6 +27,15 @@ CREATE TABLE IF NOT EXISTS usize (
27
27
  size INTEGER DEFAULT 0
28
28
  );
29
29
 
30
+ CREATE TABLE IF NOT EXISTS upeer (
31
+ src_user_id INTEGER NOT NULL,
32
+ dst_user_id INTEGER NOT NULL,
33
+ access_level INTEGER DEFAULT 0,
34
+ PRIMARY KEY(src_user_id, dst_user_id),
35
+ FOREIGN KEY(src_user_id) REFERENCES user(id),
36
+ FOREIGN KEY(dst_user_id) REFERENCES user(id)
37
+ );
38
+
30
39
  CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url);
31
40
 
32
41
  CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
@@ -8,6 +8,7 @@ from functools import wraps
8
8
  from typing import Callable, Awaitable
9
9
 
10
10
  from .log import get_logger
11
+ from .error import DatabaseLockedError
11
12
  from .config import DATA_HOME
12
13
 
13
14
  async def execute_sql(conn: aiosqlite.Connection | aiosqlite.Cursor, name: str):
@@ -28,7 +29,7 @@ async def get_connection(read_only: bool = False) -> aiosqlite.Connection:
28
29
 
29
30
  conn = await aiosqlite.connect(
30
31
  get_db_uri(DATA_HOME / 'index.db', read_only=read_only),
31
- timeout = 60, uri = True
32
+ timeout = 20, uri = True
32
33
  )
33
34
  async with conn.cursor() as c:
34
35
  await c.execute(
@@ -46,7 +47,7 @@ class SqlConnection:
46
47
 
47
48
  class SqlConnectionPool:
48
49
  _r_sem: Semaphore
49
- _w_sem: Lock | Semaphore
50
+ _w_sem: Lock
50
51
  def __init__(self):
51
52
  self._readers: list[SqlConnection] = []
52
53
  self._writer: None | SqlConnection = None
@@ -65,6 +66,17 @@ class SqlConnectionPool:
65
66
  self._readers.append(SqlConnection(conn))
66
67
  self._r_sem = Semaphore(n_read)
67
68
 
69
+ def status(self): # debug
70
+ assert self._writer
71
+ assert len(self._readers) == self.n_read
72
+ n_free_readers = sum([1 for c in self._readers if c.is_available])
73
+ n_free_writers = 1 if self._writer.is_available else 0
74
+ n_free_r_sem = self._r_sem._value
75
+ n_free_w_sem = 1 - self._w_sem.locked()
76
+ assert n_free_readers == n_free_r_sem, f"{n_free_readers} != {n_free_r_sem}"
77
+ assert n_free_writers == n_free_w_sem, f"{n_free_writers} != {n_free_w_sem}"
78
+ return f"Readers: {n_free_readers}/{self.n_read}, Writers: {n_free_writers}/{1}"
79
+
68
80
  @property
69
81
  def n_read(self):
70
82
  return len(self._readers)
@@ -142,6 +154,10 @@ async def unique_cursor(is_write: bool = False):
142
154
  connection_obj = await g_pool.get()
143
155
  try:
144
156
  yield await connection_obj.conn.cursor()
157
+ except Exception as e:
158
+ if 'database is locked' in str(e):
159
+ raise DatabaseLockedError from e
160
+ raise e
145
161
  finally:
146
162
  await g_pool.release(connection_obj)
147
163
  else:
@@ -149,10 +165,13 @@ async def unique_cursor(is_write: bool = False):
149
165
  connection_obj = await g_pool.get(w=True)
150
166
  try:
151
167
  yield await connection_obj.conn.cursor()
168
+ except Exception as e:
169
+ if 'database is locked' in str(e):
170
+ raise DatabaseLockedError from e
171
+ raise e
152
172
  finally:
153
173
  await g_pool.release(connection_obj)
154
174
 
155
- # todo: add exclusive transaction option
156
175
  @asynccontextmanager
157
176
  async def transaction():
158
177
  async with unique_cursor(is_write=True) as cur:
lfss/src/database.py CHANGED
@@ -1,5 +1,6 @@
1
1
 
2
- from typing import Optional, Literal, AsyncIterable
2
+ from typing import Optional, Literal, AsyncIterable, overload
3
+ from contextlib import asynccontextmanager
3
4
  from abc import ABC
4
5
 
5
6
  import urllib.parse
@@ -12,7 +13,8 @@ import mimetypes, mimesniff
12
13
 
13
14
  from .connection_pool import execute_sql, unique_cursor, transaction
14
15
  from .datatype import (
15
- UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents,
16
+ UserRecord, AccessLevel,
17
+ FileReadPermission, FileRecord, DirectoryRecord, PathContents,
16
18
  FileSortKey, DirSortKey, isValidFileSortKey, isValidDirSortKey
17
19
  )
18
20
  from .config import LARGE_BLOB_DIR, CHUNK_SIZE, LARGE_FILE_BYTES, MAX_MEM_FILE_BYTES
@@ -57,11 +59,16 @@ class UserConn(DBObjectBase):
57
59
  if res is None: return None
58
60
  return self.parse_record(res)
59
61
 
60
- async def get_user_by_id(self, user_id: int) -> Optional[UserRecord]:
62
+ @overload
63
+ async def get_user_by_id(self, user_id: int, throw: Literal[True]) -> UserRecord: ...
64
+ @overload
65
+ async def get_user_by_id(self, user_id: int, throw: Literal[False] = False) -> Optional[UserRecord]: ...
66
+ async def get_user_by_id(self, user_id: int, throw = False) -> Optional[UserRecord]:
61
67
  await self.cur.execute("SELECT * FROM user WHERE id = ?", (user_id, ))
62
68
  res = await self.cur.fetchone()
63
-
64
- if res is None: return None
69
+ if res is None:
70
+ if throw: raise ValueError(f"User {user_id} not found")
71
+ return None
65
72
  return self.parse_record(res)
66
73
 
67
74
  async def get_user_by_credential(self, credential: str) -> Optional[UserRecord]:
@@ -122,8 +129,58 @@ class UserConn(DBObjectBase):
122
129
  await self.cur.execute("UPDATE user SET last_active = CURRENT_TIMESTAMP WHERE username = ?", (username, ))
123
130
 
124
131
  async def delete_user(self, username: str):
132
+ await self.cur.execute("DELETE FROM upeer WHERE src_user_id = (SELECT id FROM user WHERE username = ?) OR dst_user_id = (SELECT id FROM user WHERE username = ?)", (username, username))
125
133
  await self.cur.execute("DELETE FROM user WHERE username = ?", (username, ))
126
134
  self.logger.info(f"Delete user {username}")
135
+
136
+ async def set_peer_level(self, src_user: int | str, dst_user: int | str, level: AccessLevel):
137
+ """ src_user can do [AccessLevel] to dst_user """
138
+ assert int(level) >= AccessLevel.NONE, f"Cannot set alias level to {level}"
139
+ match (src_user, dst_user):
140
+ case (int(), int()):
141
+ await self.cur.execute("INSERT OR REPLACE INTO upeer (src_user_id, dst_user_id, access_level) VALUES (?, ?, ?)", (src_user, dst_user, int(level)))
142
+ case (str(), str()):
143
+ await self.cur.execute("INSERT OR REPLACE INTO upeer (src_user_id, dst_user_id, access_level) VALUES ((SELECT id FROM user WHERE username = ?), (SELECT id FROM user WHERE username = ?), ?)", (src_user, dst_user, int(level)))
144
+ case (str(), int()):
145
+ await self.cur.execute("INSERT OR REPLACE INTO upeer (src_user_id, dst_user_id, access_level) VALUES ((SELECT id FROM user WHERE username = ?), ?, ?)", (src_user, dst_user, int(level)))
146
+ case (int(), str()):
147
+ await self.cur.execute("INSERT OR REPLACE INTO upeer (src_user_id, dst_user_id, access_level) VALUES (?, (SELECT id FROM user WHERE username = ?), ?)", (src_user, dst_user, int(level)))
148
+ case (_, _):
149
+ raise ValueError("Invalid arguments")
150
+
151
+ async def query_peer_level(self, src_user_id: int, dst_user_id: int) -> AccessLevel:
152
+ """ src_user can do [AliasLevel] to dst_user """
153
+ if src_user_id == dst_user_id:
154
+ return AccessLevel.ALL
155
+ await self.cur.execute("SELECT access_level FROM upeer WHERE src_user_id = ? AND dst_user_id = ?", (src_user_id, dst_user_id))
156
+ res = await self.cur.fetchone()
157
+ if res is None:
158
+ return AccessLevel.NONE
159
+ return AccessLevel(res[0])
160
+
161
+ async def list_peer_users(self, src_user: int | str, level: AccessLevel) -> list[UserRecord]:
162
+ """
163
+ List all users that src_user can do [AliasLevel] to, with level >= level,
164
+ Note: the returned list does not include src_user and admin users
165
+ """
166
+ assert int(level) > AccessLevel.NONE, f"Invalid level, {level}"
167
+ match src_user:
168
+ case int():
169
+ await self.cur.execute("""
170
+ SELECT * FROM user WHERE id IN (
171
+ SELECT dst_user_id FROM upeer WHERE src_user_id = ? AND access_level >= ?
172
+ )
173
+ """, (src_user, int(level)))
174
+ case str():
175
+ await self.cur.execute("""
176
+ SELECT * FROM user WHERE id IN (
177
+ SELECT dst_user_id FROM upeer WHERE src_user_id = (SELECT id FROM user WHERE username = ?) AND access_level >= ?
178
+ )
179
+ """, (src_user, int(level)))
180
+ case _:
181
+ raise ValueError("Invalid arguments")
182
+ res = await self.cur.fetchall()
183
+ return [self.parse_record(r) for r in res]
127
184
 
128
185
  class FileConn(DBObjectBase):
129
186
 
@@ -135,10 +192,15 @@ class FileConn(DBObjectBase):
135
192
  def parse_record(record) -> FileRecord:
136
193
  return FileRecord(*record)
137
194
 
138
- async def get_file_record(self, url: str) -> Optional[FileRecord]:
195
+ @overload
196
+ async def get_file_record(self, url: str, throw: Literal[True]) -> FileRecord: ...
197
+ @overload
198
+ async def get_file_record(self, url: str, throw: Literal[False] = False) -> Optional[FileRecord]: ...
199
+ async def get_file_record(self, url: str, throw = False):
139
200
  cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url = ?", (url, ))
140
201
  res = await cursor.fetchone()
141
202
  if res is None:
203
+ if throw: raise FileNotFoundError(f"File {url} not found")
142
204
  return None
143
205
  return self.parse_record(res)
144
206
 
@@ -552,7 +614,7 @@ class Database:
552
614
  if r is None:
553
615
  raise PathNotFoundError(f"File {url} not found")
554
616
  if op_user is not None:
555
- if r.owner_id != op_user.id and not op_user.is_admin:
617
+ if await check_path_permission(url, op_user) < AccessLevel.WRITE:
556
618
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot update file {url}")
557
619
  await fconn.update_file_record(url, permission=permission)
558
620
 
@@ -576,60 +638,70 @@ class Database:
576
638
  user_size_used = await fconn_r.user_size(user.id)
577
639
 
578
640
  f_id = uuid.uuid4().hex
579
- async with aiofiles.tempfile.SpooledTemporaryFile(max_size=MAX_MEM_FILE_BYTES) as f:
580
- async for chunk in blob_stream:
581
- await f.write(chunk)
582
- file_size = await f.tell()
583
- if user_size_used + file_size > user.max_storage:
584
- raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
585
-
586
- # check mime type
587
- if mime_type is None:
588
- mime_type, _ = mimetypes.guess_type(url)
589
- if mime_type is None:
590
- await f.seek(0)
591
- mime_type = mimesniff.what(await f.read(1024))
592
- if mime_type is None:
593
- mime_type = 'application/octet-stream'
641
+
642
+ async with aiofiles.tempfile.SpooledTemporaryFile(max_size=MAX_MEM_FILE_BYTES) as f:
643
+ async for chunk in blob_stream:
644
+ await f.write(chunk)
645
+ file_size = await f.tell()
646
+ if user_size_used + file_size > user.max_storage:
647
+ raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
648
+
649
+ # check mime type
650
+ if mime_type is None:
651
+ mime_type, _ = mimetypes.guess_type(url)
652
+ if mime_type is None:
594
653
  await f.seek(0)
595
-
596
- if file_size < LARGE_FILE_BYTES:
597
- blob = await f.read()
598
- async with transaction() as w_cur:
599
- fconn_w = FileConn(w_cur)
600
- await fconn_w.set_file_blob(f_id, blob)
601
- await fconn_w.set_file_record(
602
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
603
- permission=permission, external=False, mime_type=mime_type)
604
-
605
- else:
606
- async def blob_stream_tempfile():
607
- nonlocal f
608
- while True:
609
- chunk = await f.read(CHUNK_SIZE)
610
- if not chunk: break
611
- yield chunk
612
- await FileConn.set_file_blob_external(f_id, blob_stream_tempfile())
613
- async with transaction() as w_cur:
614
- await FileConn(w_cur).set_file_record(
615
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
616
- permission=permission, external=True, mime_type=mime_type)
654
+ mime_type = mimesniff.what(await f.read(1024))
655
+ if mime_type is None:
656
+ mime_type = 'application/octet-stream'
657
+ await f.seek(0)
658
+
659
+ if file_size < LARGE_FILE_BYTES:
660
+ blob = await f.read()
661
+ async with transaction() as w_cur:
662
+ fconn_w = FileConn(w_cur)
663
+ await fconn_w.set_file_blob(f_id, blob)
664
+ await fconn_w.set_file_record(
665
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
666
+ permission=permission, external=False, mime_type=mime_type)
667
+
668
+ else:
669
+ async def blob_stream_tempfile():
670
+ nonlocal f
671
+ while True:
672
+ chunk = await f.read(CHUNK_SIZE)
673
+ if not chunk: break
674
+ yield chunk
675
+ await FileConn.set_file_blob_external(f_id, blob_stream_tempfile())
676
+ async with transaction() as w_cur:
677
+ await FileConn(w_cur).set_file_record(
678
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
679
+ permission=permission, external=True, mime_type=mime_type)
617
680
  return file_size
618
681
 
619
682
  async def read_file(self, url: str, start_byte = -1, end_byte = -1) -> AsyncIterable[bytes]:
620
- # end byte is exclusive: [start_byte, end_byte)
683
+ """
684
+ Read a file from the database.
685
+ end byte is exclusive: [start_byte, end_byte)
686
+ """
687
+ # The implementation is tricky, should not keep the cursor open for too long
621
688
  validate_url(url)
622
689
  async with unique_cursor() as cur:
623
690
  fconn = FileConn(cur)
624
691
  r = await fconn.get_file_record(url)
625
692
  if r is None:
626
693
  raise FileNotFoundError(f"File {url} not found")
694
+
627
695
  if r.external:
628
- ret = fconn.get_file_blob_external(r.file_id, start_byte=start_byte, end_byte=end_byte)
696
+ _blob_stream = fconn.get_file_blob_external(r.file_id, start_byte=start_byte, end_byte=end_byte)
697
+ async def blob_stream():
698
+ async for chunk in _blob_stream:
699
+ yield chunk
629
700
  else:
701
+ blob = await fconn.get_file_blob(r.file_id, start_byte=start_byte, end_byte=end_byte)
630
702
  async def blob_stream():
631
- yield await fconn.get_file_blob(r.file_id, start_byte=start_byte, end_byte=end_byte)
632
- ret = blob_stream()
703
+ yield blob
704
+ ret = blob_stream()
633
705
  return ret
634
706
 
635
707
  async def delete_file(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
@@ -641,7 +713,8 @@ class Database:
641
713
  if r is None:
642
714
  return None
643
715
  if op_user is not None:
644
- if r.owner_id != op_user.id and not op_user.is_admin:
716
+ if r.owner_id != op_user.id and \
717
+ await check_path_permission(r.url, op_user, cursor=cur) < AccessLevel.WRITE:
645
718
  # will rollback
646
719
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot delete file {url}")
647
720
  f_id = r.file_id
@@ -661,7 +734,7 @@ class Database:
661
734
  if r is None:
662
735
  raise FileNotFoundError(f"File {old_url} not found")
663
736
  if op_user is not None:
664
- if r.owner_id != op_user.id:
737
+ if await check_path_permission(old_url, op_user) < AccessLevel.WRITE:
665
738
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
666
739
  await fconn.move_file(old_url, new_url)
667
740
 
@@ -681,20 +754,14 @@ class Database:
681
754
  assert old_url.endswith('/'), "Old path must end with /"
682
755
  assert new_url.endswith('/'), "New path must end with /"
683
756
 
684
- async with transaction() as cur:
685
- first_component = new_url.split('/')[0]
686
- if not (first_component == op_user.username or op_user.is_admin):
687
- raise PermissionDeniedError(f"Permission denied: path must start with {op_user.username}")
688
- elif op_user.is_admin:
689
- uconn = UserConn(cur)
690
- _is_user = await uconn.get_user(first_component)
691
- if not _is_user:
692
- raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
693
-
694
- # check if old path is under user's directory (non-admin)
695
- if not old_url.startswith(op_user.username + '/') and not op_user.is_admin:
696
- raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move path {old_url}")
757
+ async with unique_cursor() as cur:
758
+ if not (
759
+ await check_path_permission(old_url, op_user) >= AccessLevel.WRITE and
760
+ await check_path_permission(new_url, op_user) >= AccessLevel.WRITE
761
+ ):
762
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move path {old_url} to {new_url}")
697
763
 
764
+ async with transaction() as cur:
698
765
  fconn = FileConn(cur)
699
766
  await fconn.move_path(old_url, new_url, 'overwrite', op_user.id)
700
767
 
@@ -718,7 +785,7 @@ class Database:
718
785
 
719
786
  async def delete_path(self, url: str, op_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
720
787
  validate_url(url, is_file=False)
721
- from_owner_id = op_user.id if op_user is not None and not op_user.is_admin else None
788
+ from_owner_id = op_user.id if op_user is not None and not (op_user.is_admin or await check_path_permission(url, op_user) >= AccessLevel.WRITE) else None
722
789
 
723
790
  async with transaction() as cur:
724
791
  fconn = FileConn(cur)
@@ -786,7 +853,15 @@ class Database:
786
853
  buffer.seek(0)
787
854
  return buffer
788
855
 
789
- def check_user_permission(user: UserRecord, owner: UserRecord, file: FileRecord) -> tuple[bool, str]:
856
+ def check_file_read_permission(user: UserRecord, owner: UserRecord, file: FileRecord) -> tuple[bool, str]:
857
+ """
858
+ This does not consider alias level permission,
859
+ use check_path_permission for alias level permission check first:
860
+ ```
861
+ if await check_path_permission(path, user) < AccessLevel.READ:
862
+ read_allowed, reason = check_file_read_permission(user, owner, file)
863
+ ```
864
+ """
790
865
  if user.is_admin:
791
866
  return True, ""
792
867
 
@@ -812,4 +887,47 @@ def check_user_permission(user: UserRecord, owner: UserRecord, file: FileRecord)
812
887
  else:
813
888
  assert owner.permission == FileReadPermission.PUBLIC or owner.permission == FileReadPermission.UNSET
814
889
 
815
- return True, ""
890
+ return True, ""
891
+
892
+ async def check_path_permission(path: str, user: UserRecord, cursor: Optional[aiosqlite.Cursor] = None) -> AccessLevel:
893
+ """
894
+ Check if the user has access to the path.
895
+ If the user is admin, the user will have all access.
896
+ If the path is a file, the user will have all access if the user is the owner.
897
+ Otherwise, the user will have alias level access w.r.t. the path user.
898
+ """
899
+ if user.id == 0:
900
+ return AccessLevel.GUEST
901
+
902
+ @asynccontextmanager
903
+ async def this_cur():
904
+ if cursor is None:
905
+ async with unique_cursor() as _cur:
906
+ yield _cur
907
+ else:
908
+ yield cursor
909
+
910
+ # check if path user exists
911
+ path_username = path.split('/')[0]
912
+ async with this_cur() as cur:
913
+ uconn = UserConn(cur)
914
+ path_user = await uconn.get_user(path_username)
915
+ if path_user is None:
916
+ raise PathNotFoundError(f"Invalid path: {path_username} is not a valid username")
917
+
918
+ # check if user is admin
919
+ if user.is_admin or user.username == path_username:
920
+ return AccessLevel.ALL
921
+
922
+ # if the path is a file, check if the user is the owner
923
+ if not path.endswith('/'):
924
+ async with this_cur() as cur:
925
+ fconn = FileConn(cur)
926
+ file = await fconn.get_file_record(path)
927
+ if file and file.owner_id == user.id:
928
+ return AccessLevel.ALL
929
+
930
+ # check alias level
931
+ async with this_cur() as cur:
932
+ uconn = UserConn(cur)
933
+ return await uconn.query_peer_level(user.id, path_user.id)
lfss/src/datatype.py CHANGED
@@ -8,6 +8,13 @@ class FileReadPermission(IntEnum):
8
8
  PROTECTED = 2 # accessible by any user
9
9
  PRIVATE = 3 # accessible by owner only (including admin)
10
10
 
11
+ class AccessLevel(IntEnum):
12
+ GUEST = -1 # guest, no permission
13
+ NONE = 0 # no permission
14
+ READ = 1 # read permission
15
+ WRITE = 2 # write/delete permission
16
+ ALL = 10 # all permission, currently same as WRITE
17
+
11
18
  @dataclasses.dataclass
12
19
  class UserRecord:
13
20
  id: int
lfss/src/error.py CHANGED
@@ -1,6 +1,9 @@
1
+ import sqlite3
1
2
 
2
3
  class LFSSExceptionBase(Exception):...
3
4
 
5
+ class DatabaseLockedError(LFSSExceptionBase, sqlite3.DatabaseError):...
6
+
4
7
  class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
5
8
 
6
9
  class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
lfss/src/server.py CHANGED
@@ -16,10 +16,10 @@ from .stat import RequestDB
16
16
  from .config import MAX_BUNDLE_BYTES, CHUNK_SIZE
17
17
  from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
18
18
  from .connection_pool import global_connection_init, global_connection_close, unique_cursor
19
- from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn
19
+ from .database import Database, DECOY_USER, check_file_read_permission, check_path_permission, UserConn, FileConn
20
20
  from .database import delayed_log_activity, delayed_log_access
21
21
  from .datatype import (
22
- FileReadPermission, FileRecord, UserRecord, PathContents,
22
+ FileReadPermission, FileRecord, UserRecord, PathContents, AccessLevel,
23
23
  FileSortKey, DirSortKey
24
24
  )
25
25
  from .thumb import get_thumb
@@ -54,6 +54,7 @@ def handle_exception(fn):
54
54
  if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
55
55
  if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
56
56
  if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
57
+ if isinstance(e, DatabaseLockedError): raise HTTPException(status_code=503, detail=str(e))
57
58
  logger.error(f"Uncaptured error in {fn.__name__}: {e}")
58
59
  raise
59
60
  return wrapper
@@ -228,39 +229,37 @@ async def get_file_impl(
228
229
  if path == "": path = "/"
229
230
  if path.endswith("/"):
230
231
  # return file under the path as json
231
- async with unique_cursor() as conn:
232
- fconn = FileConn(conn)
232
+ async with unique_cursor() as cur:
233
+ fconn = FileConn(cur)
233
234
  if user.id == 0:
234
235
  raise HTTPException(status_code=401, detail="Permission denied, credential required")
235
236
  if thumb:
236
237
  return await emit_thumbnail(path, download, create_time=None)
237
238
 
238
239
  if path == "/":
240
+ peer_users = await UserConn(cur).list_peer_users(user.id, AccessLevel.READ)
239
241
  return PathContents(
240
- dirs = await fconn.list_root_dirs(user.username, skim=True) \
242
+ dirs = await fconn.list_root_dirs(user.username, *[x.username for x in peer_users], skim=True) \
241
243
  if not user.is_admin else await fconn.list_root_dirs(skim=True),
242
244
  files = []
243
245
  )
244
246
 
245
- if not path.startswith(f"{user.username}/") and not user.is_admin:
246
- raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
247
+ if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
248
+ raise HTTPException(status_code=403, detail="Permission denied")
247
249
 
248
250
  return await fconn.list_path(path)
249
251
 
250
252
  # handle file query
251
- async with unique_cursor() as conn:
252
- fconn = FileConn(conn)
253
- file_record = await fconn.get_file_record(path)
254
- if not file_record:
255
- raise HTTPException(status_code=404, detail="File not found")
256
-
257
- uconn = UserConn(conn)
258
- owner = await uconn.get_user_by_id(file_record.owner_id)
259
-
260
- assert owner is not None, "Owner not found"
261
- allow_access, reason = check_user_permission(user, owner, file_record)
262
- if not allow_access:
263
- raise HTTPException(status_code=403, detail=reason)
253
+ async with unique_cursor() as cur:
254
+ fconn = FileConn(cur)
255
+ file_record = await fconn.get_file_record(path, throw=True)
256
+ uconn = UserConn(cur)
257
+ owner = await uconn.get_user_by_id(file_record.owner_id, throw=True)
258
+
259
+ if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
260
+ allow_access, reason = check_file_read_permission(user, owner, file_record)
261
+ if not allow_access:
262
+ raise HTTPException(status_code=403 if user.id != 0 else 401, detail=reason)
264
263
 
265
264
  req_range = request.headers.get("Range", None)
266
265
  if not req_range is None:
@@ -326,17 +325,11 @@ async def put_file(
326
325
  ):
327
326
  path = ensure_uri_compnents(path)
328
327
  assert not path.endswith("/"), "Path must not end with /"
329
- if not path.startswith(f"{user.username}/"):
330
- if not user.is_admin:
331
- logger.debug(f"Reject put request from {user.username} to {path}")
332
- raise HTTPException(status_code=403, detail="Permission denied")
333
- else:
334
- first_comp = path.split("/")[0]
335
- async with unique_cursor() as c:
336
- uconn = UserConn(c)
337
- owner = await uconn.get_user(first_comp)
338
- if not owner:
339
- raise HTTPException(status_code=404, detail="Owner not found")
328
+
329
+ access_level = await check_path_permission(path, user)
330
+ if access_level < AccessLevel.WRITE:
331
+ logger.debug(f"Reject put request from {user.username} to {path}")
332
+ raise HTTPException(status_code=403, detail="Permission denied")
340
333
 
341
334
  logger.info(f"PUT {path}, user: {user.username}")
342
335
  exists_flag = False
@@ -352,7 +345,7 @@ async def put_file(
352
345
  "Content-Type": "application/json",
353
346
  }, content=json.dumps({"url": path}))
354
347
  exists_flag = True
355
- if not user.is_admin and not file_record.owner_id == user.id:
348
+ if await check_path_permission(path, user) < AccessLevel.WRITE:
356
349
  raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
357
350
  await db.delete_file(path)
358
351
 
@@ -386,17 +379,11 @@ async def post_file(
386
379
  ):
387
380
  path = ensure_uri_compnents(path)
388
381
  assert not path.endswith("/"), "Path must not end with /"
389
- if not path.startswith(f"{user.username}/"):
390
- if not user.is_admin:
391
- logger.debug(f"Reject put request from {user.username} to {path}")
392
- raise HTTPException(status_code=403, detail="Permission denied")
393
- else:
394
- first_comp = path.split("/")[0]
395
- async with unique_cursor() as conn:
396
- uconn = UserConn(conn)
397
- owner = await uconn.get_user(first_comp)
398
- if not owner:
399
- raise HTTPException(status_code=404, detail="Owner not found")
382
+
383
+ access_level = await check_path_permission(path, user)
384
+ if access_level < AccessLevel.WRITE:
385
+ logger.debug(f"Reject post request from {user.username} to {path}")
386
+ raise HTTPException(status_code=403, detail="Permission denied")
400
387
 
401
388
  logger.info(f"POST {path}, user: {user.username}")
402
389
  exists_flag = False
@@ -412,7 +399,7 @@ async def post_file(
412
399
  "Content-Type": "application/json",
413
400
  }, content=json.dumps({"url": path}))
414
401
  exists_flag = True
415
- if not user.is_admin and not file_record.owner_id == user.id:
402
+ if await check_path_permission(path, user) < AccessLevel.WRITE:
416
403
  raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
417
404
  await db.delete_file(path)
418
405
 
@@ -431,7 +418,7 @@ async def post_file(
431
418
  @handle_exception
432
419
  async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
433
420
  path = ensure_uri_compnents(path)
434
- if not path.startswith(f"{user.username}/") and not user.is_admin:
421
+ if await check_path_permission(path, user) < AccessLevel.WRITE:
435
422
  raise HTTPException(status_code=403, detail="Permission denied")
436
423
 
437
424
  logger.info(f"DELETE {path}, user: {user.username}")
@@ -458,6 +445,7 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
458
445
  if not path == "" and path[0] == "/": # adapt to both /path and path
459
446
  path = path[1:]
460
447
 
448
+ # TODO: may check peer users here
461
449
  owner_records_cache: dict[int, UserRecord] = {} # cache owner records, ID -> UserRecord
462
450
  async def is_access_granted(file_record: FileRecord):
463
451
  owner_id = file_record.owner_id
@@ -465,11 +453,10 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
465
453
  if owner is None:
466
454
  async with unique_cursor() as conn:
467
455
  uconn = UserConn(conn)
468
- owner = await uconn.get_user_by_id(owner_id)
469
- assert owner is not None, "Owner not found"
456
+ owner = await uconn.get_user_by_id(owner_id, throw=True)
470
457
  owner_records_cache[owner_id] = owner
471
458
 
472
- allow_access, _ = check_user_permission(user, owner, file_record)
459
+ allow_access, _ = check_file_read_permission(user, owner, file_record)
473
460
  return allow_access
474
461
 
475
462
  async with unique_cursor() as conn:
@@ -502,23 +489,20 @@ async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
502
489
  logger.info(f"GET meta({path}), user: {user.username}")
503
490
  path = ensure_uri_compnents(path)
504
491
  is_file = not path.endswith("/")
505
- async with unique_cursor() as conn:
506
- fconn = FileConn(conn)
492
+ async with unique_cursor() as cur:
493
+ fconn = FileConn(cur)
507
494
  if is_file:
508
- record = await fconn.get_file_record(path)
509
- if not record:
510
- raise HTTPException(status_code=404, detail="File not found")
511
- if not path.startswith(f"{user.username}/") and not user.is_admin:
512
- uconn = UserConn(conn)
513
- owner = await uconn.get_user_by_id(record.owner_id)
514
- assert owner is not None, "Owner not found"
515
- is_allowed, reason = check_user_permission(user, owner, record)
495
+ record = await fconn.get_file_record(path, throw=True)
496
+ if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
497
+ uconn = UserConn(cur)
498
+ owner = await uconn.get_user_by_id(record.owner_id, throw=True)
499
+ is_allowed, reason = check_file_read_permission(user, owner, record)
516
500
  if not is_allowed:
517
501
  raise HTTPException(status_code=403, detail=reason)
518
502
  else:
519
- record = await fconn.get_path_record(path)
520
- if not path.startswith(f"{user.username}/") and not user.is_admin:
503
+ if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
521
504
  raise HTTPException(status_code=403, detail="Permission denied")
505
+ record = await fconn.get_path_record(path)
522
506
  return record
523
507
 
524
508
  @router_api.post("/meta")
@@ -559,15 +543,14 @@ async def update_file_meta(
559
543
 
560
544
  return Response(status_code=200, content="OK")
561
545
 
562
- async def validate_path_permission(path: str, user: UserRecord):
546
+ async def validate_path_read_permission(path: str, user: UserRecord):
563
547
  if not path.endswith("/"):
564
548
  raise HTTPException(status_code=400, detail="Path must end with /")
565
- if not path.startswith(f"{user.username}/") and not user.is_admin:
549
+ if not await check_path_permission(path, user) >= AccessLevel.READ:
566
550
  raise HTTPException(status_code=403, detail="Permission denied")
567
-
568
551
  @router_api.get("/count-files")
569
552
  async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
570
- await validate_path_permission(path, user)
553
+ await validate_path_read_permission(path, user)
571
554
  path = ensure_uri_compnents(path)
572
555
  async with unique_cursor() as conn:
573
556
  fconn = FileConn(conn)
@@ -578,7 +561,7 @@ async def list_files(
578
561
  order_by: FileSortKey = "", order_desc: bool = False,
579
562
  flat: bool = False, user: UserRecord = Depends(registered_user)
580
563
  ):
581
- await validate_path_permission(path, user)
564
+ await validate_path_read_permission(path, user)
582
565
  path = ensure_uri_compnents(path)
583
566
  async with unique_cursor() as conn:
584
567
  fconn = FileConn(conn)
@@ -590,7 +573,7 @@ async def list_files(
590
573
 
591
574
  @router_api.get("/count-dirs")
592
575
  async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
593
- await validate_path_permission(path, user)
576
+ await validate_path_read_permission(path, user)
594
577
  path = ensure_uri_compnents(path)
595
578
  async with unique_cursor() as conn:
596
579
  fconn = FileConn(conn)
@@ -601,7 +584,7 @@ async def list_dirs(
601
584
  order_by: DirSortKey = "", order_desc: bool = False,
602
585
  skim: bool = True, user: UserRecord = Depends(registered_user)
603
586
  ):
604
- await validate_path_permission(path, user)
587
+ await validate_path_read_permission(path, user)
605
588
  path = ensure_uri_compnents(path)
606
589
  async with unique_cursor() as conn:
607
590
  fconn = FileConn(conn)
lfss/src/utils.py CHANGED
@@ -47,13 +47,15 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
47
47
  """
48
48
  def debounce_wrap(func):
49
49
  task_record: tuple[str, asyncio.Task] | None = None
50
+ fn_execution_lock = Lock()
50
51
  last_execution_time = 0
51
52
 
52
53
  async def delayed_func(*args, **kwargs):
53
54
  nonlocal last_execution_time
54
55
  await asyncio.sleep(delay)
55
- await func(*args, **kwargs)
56
- last_execution_time = time.monotonic()
56
+ async with fn_execution_lock:
57
+ await func(*args, **kwargs)
58
+ last_execution_time = time.monotonic()
57
59
 
58
60
  @functools.wraps(func)
59
61
  async def wrapper(*args, **kwargs):
@@ -64,10 +66,11 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
64
66
  task_record[1].cancel()
65
67
  g_debounce_tasks.pop(task_record[0], None)
66
68
 
67
- if time.monotonic() - last_execution_time > max_wait:
68
- await func(*args, **kwargs)
69
- last_execution_time = time.monotonic()
70
- return
69
+ async with fn_execution_lock:
70
+ if time.monotonic() - last_execution_time > max_wait:
71
+ await func(*args, **kwargs)
72
+ last_execution_time = time.monotonic()
73
+ return
71
74
 
72
75
  task = asyncio.create_task(delayed_func(*args, **kwargs))
73
76
  task_uid = uuid4().hex
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.8.4
3
+ Version: 0.9.0
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -1,7 +1,7 @@
1
1
  Readme.md,sha256=J1tGk7B9EyIXT-RN7VGz_229UeKvZHVLpn1FvzNDxL4,1538
2
2
  docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
- docs/Permission.md,sha256=9r9nEmhqfz18RTS8FI0fZ9F0a31r86OoAyx3EQxxpk0,2317
4
- frontend/api.js,sha256=wUJNAkL8QigAiwR_jaMPUhCQEsL-lp0wZ6XeueYgunE,18049
3
+ docs/Permission.md,sha256=mvK8gVBBgoIFJqikcaReU_bUo-mTq_ECqJaDDJoQF7Q,3126
4
+ frontend/api.js,sha256=hMV6Fc1JxkFQgv7BV1Y_Su7pqsWeF_92hPMmDBcXC04,18485
5
5
  frontend/index.html,sha256=-k0bJ5FRqdl_H-O441D_H9E-iejgRCaL_z5UeYaS2qc,3384
6
6
  frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
7
7
  frontend/info.js,sha256=xGUJPCSrtDhuSu0ELLQZ77PmVWldg-prU1mwQGbdEoA,5797
@@ -16,29 +16,29 @@ frontend/thumb.css,sha256=rNsx766amYS2DajSQNabhpQ92gdTpNoQKmV69OKvtpI,295
16
16
  frontend/thumb.js,sha256=46ViD2TlTTWy0fx6wjoAs_5CQ4ajYB90vVzM7UO2IHw,6182
17
17
  frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
18
18
  lfss/api/__init__.py,sha256=c_-GokKJ_3REgve16AwgXRfFyl9mwy7__FxCIIVlk1Q,6660
19
- lfss/api/connector.py,sha256=e2nhqrRGWixSJXRVDBxadq9oeiL0sGWLaL7FFzvLFJ8,11231
19
+ lfss/api/connector.py,sha256=sSYy8gDewOosQiOzn8rvl7NsfFkIuhumHDefAVCgess,11573
20
20
  lfss/cli/__init__.py,sha256=lPwPmqpa7EXQ4zlU7E7LOe6X2kw_xATGdwoHphUEirA,827
21
21
  lfss/cli/balance.py,sha256=R2rbO2tg9TVnnQIVeU0GJVeMS-5LDhEdk4mbOE9qGq0,4121
22
- lfss/cli/cli.py,sha256=WVxDtIYCgFkEp9HoVLGi7AAhZJi5BCML7uT5D4yVcuE,8262
22
+ lfss/cli/cli.py,sha256=iQkZm5Ltlhw7EWM4gOv_N0vjxiteGDH_aGhh06YMPYk,8066
23
23
  lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
24
24
  lfss/cli/serve.py,sha256=T-jz_PJcaY9FJRRfyWV9MbjDI73YdOOCn4Nr2PO-s0c,993
25
- lfss/cli/user.py,sha256=uqHQ7onddTjJAYg3B1DIc8hDl0aCkIMZolLKhQrBd0k,4046
25
+ lfss/cli/user.py,sha256=4ynarVvSybxEQaoAzCn2dN2h6-9A61XDNQ-83-lwK4s,5364
26
26
  lfss/cli/vacuum.py,sha256=4TMMYC_5yEt7jeaFTVC3iX0X6aUzYXBiCsrcIYgw_uA,3695
27
- lfss/sql/init.sql,sha256=C-JtQAlaOjESI8uoF1Y_9dKukEVSw5Ll-7yA3gG-XHU,1210
27
+ lfss/sql/init.sql,sha256=8LjHx0TBCkBD62xFfssSeHDqKYVQQJkZAg4rSm046f4,1496
28
28
  lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
29
29
  lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
30
  lfss/src/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
31
31
  lfss/src/config.py,sha256=7k_6L_aG7vD-lVcmgpwMIg-ggpMMDNU8IbaB4y1effE,861
32
- lfss/src/connection_pool.py,sha256=4YULPcmndy33FyBSo9w1fZjAlBX2q-xPP27xJOTA06I,5228
33
- lfss/src/database.py,sha256=zoiBm7CVHHV4TqwmK6lPnZvK9mzDNtrNvAJCRaIYMU8,36302
34
- lfss/src/datatype.py,sha256=1xdxSKhpJXoBKumUokL3zQ2VyZ0Wwp8q6PaJf1idVw0,2435
35
- lfss/src/error.py,sha256=Bh_GUtuNsMSMIKbFreU4CfZzL96TZdNAIcgmDxvGbQ0,333
32
+ lfss/src/connection_pool.py,sha256=-tePasJxiZZ73ymgWf_kFnaKouc4Rrr4K6EXwjb7Mm4,6141
33
+ lfss/src/database.py,sha256=jahoLa3kU6wGovwyfuvaMuGI8rLxcJYSfMkTT8_LI3c,41883
34
+ lfss/src/datatype.py,sha256=27UB7-l9SICy5lAvKjdzpTL_GohZjzstQcr9PtAq7nM,2709
35
+ lfss/src/error.py,sha256=oUQPkXzrlJ6Mtvm26T_3SYW_4j9unfudG3HMa1Q-JF0,421
36
36
  lfss/src/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
37
- lfss/src/server.py,sha256=YLsp6bab7q0I2hI4uUYIiWc2S0k6d6bbMaweg6VbVV4,23743
37
+ lfss/src/server.py,sha256=TMsZBt-hF4dh_-e_v5odki09S36kJ33Gi_GbtUnGQ-M,23310
38
38
  lfss/src/stat.py,sha256=WlRiiAl0rHX9oPi3thi6K4GKn70WHpClSDCt7iZUow4,3197
39
39
  lfss/src/thumb.py,sha256=qjCNMpnCozMuzkhm-2uAYy1eAuYTeWG6xqs-13HX-7k,3266
40
- lfss/src/utils.py,sha256=DxjHabdiISMkrm1WQlpsZFKL3by6YrzBNQaDt_uZlRk,5744
41
- lfss-0.8.4.dist-info/METADATA,sha256=OUvtod8R5Z7DnNLczVG6FpsHEQ2J1ct8QlbdZhU2fIk,2298
42
- lfss-0.8.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
43
- lfss-0.8.4.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
44
- lfss-0.8.4.dist-info/RECORD,,
40
+ lfss/src/utils.py,sha256=XHBDBODU6RPpYywArmMBVnghPk3crPLrSW4cKxqiP5Q,5887
41
+ lfss-0.9.0.dist-info/METADATA,sha256=QNSYV-4VzdS4VCmKtSvjSMurXTb1qvUhvW-Wvpz3dcA,2298
42
+ lfss-0.9.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
43
+ lfss-0.9.0.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
44
+ lfss-0.9.0.dist-info/RECORD,,
File without changes