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 +38 -26
- frontend/api.js +21 -10
- lfss/api/connector.py +10 -4
- lfss/cli/cli.py +3 -8
- lfss/cli/user.py +28 -1
- lfss/sql/init.sql +9 -0
- lfss/src/connection_pool.py +22 -3
- lfss/src/database.py +184 -66
- lfss/src/datatype.py +7 -0
- lfss/src/error.py +3 -0
- lfss/src/server.py +50 -67
- lfss/src/utils.py +9 -6
- {lfss-0.8.4.dist-info → lfss-0.9.0.dist-info}/METADATA +1 -1
- {lfss-0.8.4.dist-info → lfss-0.9.0.dist-info}/RECORD +16 -16
- {lfss-0.8.4.dist-info → lfss-0.9.0.dist-info}/WHEEL +0 -0
- {lfss-0.8.4.dist-info → lfss-0.9.0.dist-info}/entry_points.txt +0 -0
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
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
44
|
-
-
|
45
|
-
|
46
|
-
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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__(
|
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
|
-
|
10
|
-
|
11
|
-
|
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);
|
lfss/src/connection_pool.py
CHANGED
@@ -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 =
|
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
|
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,
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
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
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
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
|
-
|
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
|
-
|
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
|
632
|
-
|
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
|
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
|
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
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
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
|
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,
|
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
|
232
|
-
fconn = FileConn(
|
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
|
246
|
-
raise HTTPException(status_code=403, detail="Permission denied
|
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
|
252
|
-
fconn = FileConn(
|
253
|
-
file_record = await fconn.get_file_record(path)
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
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
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
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
|
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
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
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
|
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
|
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, _ =
|
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
|
506
|
-
fconn = FileConn(
|
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
|
510
|
-
|
511
|
-
|
512
|
-
|
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
|
-
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
-
|
56
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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,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=
|
4
|
-
frontend/api.js,sha256=
|
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=
|
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=
|
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=
|
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=
|
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
|
33
|
-
lfss/src/database.py,sha256=
|
34
|
-
lfss/src/datatype.py,sha256=
|
35
|
-
lfss/src/error.py,sha256=
|
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=
|
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=
|
41
|
-
lfss-0.
|
42
|
-
lfss-0.
|
43
|
-
lfss-0.
|
44
|
-
lfss-0.
|
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
|
File without changes
|