lfss 0.1.0__py3-none-any.whl → 0.2.1__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.
- Readme.md +28 -0
- docs/Permission.md +29 -0
- frontend/api.js +178 -0
- frontend/index.html +60 -0
- frontend/scripts.js +419 -0
- frontend/styles.css +211 -0
- frontend/utils.js +83 -0
- lfss/cli/panel.py +45 -0
- lfss/cli/user.py +16 -3
- lfss/src/database.py +147 -38
- lfss/src/error.py +6 -0
- lfss/src/server.py +99 -28
- {lfss-0.1.0.dist-info → lfss-0.2.1.dist-info}/METADATA +13 -8
- lfss-0.2.1.dist-info/RECORD +21 -0
- {lfss-0.1.0.dist-info → lfss-0.2.1.dist-info}/entry_points.txt +1 -0
- lfss-0.1.0.dist-info/RECORD +0 -12
- {lfss-0.1.0.dist-info → lfss-0.2.1.dist-info}/WHEEL +0 -0
lfss/cli/panel.py
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
""" A static file server to serve frontend panel """
|
2
|
+
import uvicorn
|
3
|
+
from fastapi import FastAPI
|
4
|
+
from fastapi.staticfiles import StaticFiles
|
5
|
+
|
6
|
+
import argparse
|
7
|
+
from contextlib import asynccontextmanager
|
8
|
+
from pathlib import Path
|
9
|
+
|
10
|
+
__this_dir = Path(__file__).parent
|
11
|
+
__frontend_dir = __this_dir.parent.parent / "frontend"
|
12
|
+
|
13
|
+
browser_open_config = {
|
14
|
+
"enabled": True,
|
15
|
+
"host": "",
|
16
|
+
"port": 0
|
17
|
+
}
|
18
|
+
|
19
|
+
@asynccontextmanager
|
20
|
+
async def app_lifespan(app: FastAPI):
|
21
|
+
if browser_open_config["enabled"]:
|
22
|
+
import webbrowser
|
23
|
+
webbrowser.open(f"http://{browser_open_config['host']}:{browser_open_config['port']}")
|
24
|
+
yield
|
25
|
+
|
26
|
+
assert (__frontend_dir / "index.html").exists(), "Frontend panel not found"
|
27
|
+
|
28
|
+
app = FastAPI(lifespan=app_lifespan)
|
29
|
+
app.mount("/", StaticFiles(directory=__frontend_dir, html=True), name="static")
|
30
|
+
|
31
|
+
def main():
|
32
|
+
parser = argparse.ArgumentParser(description="Serve frontend panel")
|
33
|
+
parser.add_argument("--host", default="127.0.0.1", help="Host to serve")
|
34
|
+
parser.add_argument("--port", type=int, default=8009, help="Port to serve")
|
35
|
+
parser.add_argument("--open", action="store_true", help="Open browser")
|
36
|
+
args = parser.parse_args()
|
37
|
+
|
38
|
+
browser_open_config["enabled"] = args.open
|
39
|
+
browser_open_config["host"] = args.host
|
40
|
+
browser_open_config["port"] = args.port
|
41
|
+
|
42
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
43
|
+
|
44
|
+
if __name__ == "__main__":
|
45
|
+
main()
|
lfss/cli/user.py
CHANGED
@@ -1,5 +1,14 @@
|
|
1
1
|
import argparse, asyncio
|
2
|
-
from ..src.database import Database
|
2
|
+
from ..src.database import Database, FileReadPermission
|
3
|
+
|
4
|
+
def parse_storage_size(s: str) -> int:
|
5
|
+
if s[-1] in 'Kk':
|
6
|
+
return int(s[:-1]) * 1024
|
7
|
+
if s[-1] in 'Mm':
|
8
|
+
return int(s[:-1]) * 1024 * 1024
|
9
|
+
if s[-1] in 'Gg':
|
10
|
+
return int(s[:-1]) * 1024 * 1024 * 1024
|
11
|
+
return int(s)
|
3
12
|
|
4
13
|
async def _main():
|
5
14
|
parser = argparse.ArgumentParser()
|
@@ -8,6 +17,8 @@ async def _main():
|
|
8
17
|
sp_add.add_argument('username', type=str)
|
9
18
|
sp_add.add_argument('password', type=str)
|
10
19
|
sp_add.add_argument('--admin', action='store_true')
|
20
|
+
sp_add.add_argument('--permission', type=FileReadPermission, default=FileReadPermission.UNSET)
|
21
|
+
sp_add.add_argument('--max-storage', type=parse_storage_size, default="1G")
|
11
22
|
|
12
23
|
sp_delete = sp.add_parser('delete')
|
13
24
|
sp_delete.add_argument('username', type=str)
|
@@ -22,6 +33,8 @@ async def _main():
|
|
22
33
|
sp_set.add_argument('username', type=str)
|
23
34
|
sp_set.add_argument('-p', '--password', type=str, default=None)
|
24
35
|
sp_set.add_argument('-a', '--admin', type=parse_bool, default=None)
|
36
|
+
sp_set.add_argument('--permission', type=int, default=None)
|
37
|
+
sp_set.add_argument('--max-storage', type=parse_storage_size, default=None)
|
25
38
|
|
26
39
|
sp_list = sp.add_parser('list')
|
27
40
|
sp_list.add_argument("-l", "--long", action="store_true")
|
@@ -31,7 +44,7 @@ async def _main():
|
|
31
44
|
|
32
45
|
try:
|
33
46
|
if args.subparser_name == 'add':
|
34
|
-
await conn.user.create_user(args.username, args.password, args.admin)
|
47
|
+
await conn.user.create_user(args.username, args.password, args.admin, max_storage=args.max_storage, permission=args.permission)
|
35
48
|
user = await conn.user.get_user(args.username)
|
36
49
|
assert user is not None
|
37
50
|
print('User created, credential:', user.credential)
|
@@ -50,7 +63,7 @@ async def _main():
|
|
50
63
|
if user is None:
|
51
64
|
print('User not found')
|
52
65
|
exit(1)
|
53
|
-
await conn.user.
|
66
|
+
await conn.user.update_user(user.username, args.password, args.admin, max_storage=args.max_storage, permission=args.permission)
|
54
67
|
user = await conn.user.get_user(args.username)
|
55
68
|
assert user is not None
|
56
69
|
print('User updated, credential:', user.credential)
|
lfss/src/database.py
CHANGED
@@ -14,6 +14,7 @@ from asyncio import Lock
|
|
14
14
|
from .config import DATA_HOME
|
15
15
|
from .log import get_logger
|
16
16
|
from .utils import decode_uri_compnents
|
17
|
+
from .error import *
|
17
18
|
|
18
19
|
_g_conn: Optional[aiosqlite.Connection] = None
|
19
20
|
|
@@ -40,6 +41,12 @@ class DBConnBase(ABC):
|
|
40
41
|
async def commit(self):
|
41
42
|
await self.conn.commit()
|
42
43
|
|
44
|
+
class FileReadPermission(IntEnum):
|
45
|
+
UNSET = 0 # not set
|
46
|
+
PUBLIC = 1 # accessible by anyone
|
47
|
+
PROTECTED = 2 # accessible by any user
|
48
|
+
PRIVATE = 3 # accessible by owner only (including admin)
|
49
|
+
|
43
50
|
@dataclasses.dataclass
|
44
51
|
class DBUserRecord:
|
45
52
|
id: int
|
@@ -48,11 +55,13 @@ class DBUserRecord:
|
|
48
55
|
is_admin: bool
|
49
56
|
create_time: str
|
50
57
|
last_active: str
|
58
|
+
max_storage: int
|
59
|
+
permission: 'FileReadPermission'
|
51
60
|
|
52
61
|
def __str__(self):
|
53
|
-
return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active})"
|
62
|
+
return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}), storage={self.max_storage}, permission={self.permission}"
|
54
63
|
|
55
|
-
DECOY_USER = DBUserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00')
|
64
|
+
DECOY_USER = DBUserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
|
56
65
|
class UserConn(DBConnBase):
|
57
66
|
|
58
67
|
@staticmethod
|
@@ -61,6 +70,7 @@ class UserConn(DBConnBase):
|
|
61
70
|
|
62
71
|
async def init(self):
|
63
72
|
await super().init()
|
73
|
+
# default to 1GB (1024x1024x1024 bytes)
|
64
74
|
await self.conn.execute('''
|
65
75
|
CREATE TABLE IF NOT EXISTS user (
|
66
76
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
@@ -68,9 +78,18 @@ class UserConn(DBConnBase):
|
|
68
78
|
credential VARCHAR(255) NOT NULL,
|
69
79
|
is_admin BOOLEAN DEFAULT FALSE,
|
70
80
|
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
71
|
-
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
81
|
+
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
82
|
+
max_storage INTEGER DEFAULT 1073741824,
|
83
|
+
permission INTEGER DEFAULT 0
|
72
84
|
)
|
73
85
|
''')
|
86
|
+
await self.conn.execute('''
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username)
|
88
|
+
''')
|
89
|
+
await self.conn.execute('''
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential)
|
91
|
+
''')
|
92
|
+
|
74
93
|
return self
|
75
94
|
|
76
95
|
async def get_user(self, username: str) -> Optional[DBUserRecord]:
|
@@ -94,37 +113,43 @@ class UserConn(DBConnBase):
|
|
94
113
|
if res is None: return None
|
95
114
|
return self.parse_record(res)
|
96
115
|
|
97
|
-
async def create_user(
|
116
|
+
async def create_user(
|
117
|
+
self, username: str, password: str, is_admin: bool = False,
|
118
|
+
max_storage: int = 1073741824, permission: FileReadPermission = FileReadPermission.UNSET
|
119
|
+
) -> int:
|
98
120
|
assert not username.startswith('_'), "Error: reserved username"
|
99
121
|
assert not ('/' in username or len(username) > 255), "Invalid username"
|
100
122
|
assert urllib.parse.quote(username) == username, "Invalid username, must be URL safe"
|
101
123
|
self.logger.debug(f"Creating user {username}")
|
102
124
|
credential = hash_credential(username, password)
|
103
125
|
assert await self.get_user(username) is None, "Duplicate username"
|
104
|
-
async with self.conn.execute("INSERT INTO user (username, credential, is_admin) VALUES (?, ?, ?)", (username, credential, is_admin)) as cursor:
|
126
|
+
async with self.conn.execute("INSERT INTO user (username, credential, is_admin, max_storage, permission) VALUES (?, ?, ?, ?, ?)", (username, credential, is_admin, max_storage, permission)) as cursor:
|
105
127
|
self.logger.info(f"User {username} created")
|
106
128
|
assert cursor.lastrowid is not None
|
107
129
|
return cursor.lastrowid
|
108
130
|
|
109
|
-
async def
|
131
|
+
async def update_user(
|
132
|
+
self, username: str, password: Optional[str] = None, is_admin: Optional[bool] = None,
|
133
|
+
max_storage: Optional[int] = None, permission: Optional[FileReadPermission] = None
|
134
|
+
):
|
110
135
|
assert not username.startswith('_'), "Error: reserved username"
|
111
136
|
assert not ('/' in username or len(username) > 255), "Invalid username"
|
112
137
|
assert urllib.parse.quote(username) == username, "Invalid username, must be URL safe"
|
138
|
+
|
139
|
+
current_record = await self.get_user(username)
|
140
|
+
if current_record is None:
|
141
|
+
raise ValueError(f"User {username} not found")
|
142
|
+
|
113
143
|
if password is not None:
|
114
144
|
credential = hash_credential(username, password)
|
115
145
|
else:
|
116
|
-
|
117
|
-
res = await cursor.fetchone()
|
118
|
-
assert res is not None, f"User {username} not found"
|
119
|
-
credential = res[0]
|
146
|
+
credential = current_record.credential
|
120
147
|
|
121
|
-
if is_admin is None:
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
await self.conn.execute("UPDATE user SET credential = ?, is_admin = ? WHERE username = ?", (credential, is_admin, username))
|
148
|
+
if is_admin is None: is_admin = current_record.is_admin
|
149
|
+
if max_storage is None: max_storage = current_record.max_storage
|
150
|
+
if permission is None: permission = current_record.permission
|
151
|
+
|
152
|
+
await self.conn.execute("UPDATE user SET credential = ?, is_admin = ?, max_storage = ?, permission = ? WHERE username = ?", (credential, is_admin, max_storage, permission, username))
|
128
153
|
self.logger.info(f"User {username} updated")
|
129
154
|
|
130
155
|
async def all(self):
|
@@ -139,11 +164,6 @@ class UserConn(DBConnBase):
|
|
139
164
|
await self.conn.execute("DELETE FROM user WHERE username = ?", (username, ))
|
140
165
|
self.logger.info(f"Delete user {username}")
|
141
166
|
|
142
|
-
class FileReadPermission(IntEnum):
|
143
|
-
PUBLIC = 0 # accessible by anyone
|
144
|
-
PROTECTED = 1 # accessible by any user
|
145
|
-
PRIVATE = 2 # accessible by owner only (including admin)
|
146
|
-
|
147
167
|
@dataclasses.dataclass
|
148
168
|
class FileDBRecord:
|
149
169
|
url: str
|
@@ -196,6 +216,25 @@ class FileConn(DBConnBase):
|
|
196
216
|
)
|
197
217
|
''')
|
198
218
|
|
219
|
+
# user file size table
|
220
|
+
await self.conn.execute('''
|
221
|
+
CREATE TABLE IF NOT EXISTS usize (
|
222
|
+
user_id INTEGER PRIMARY KEY,
|
223
|
+
size INTEGER DEFAULT 0
|
224
|
+
)
|
225
|
+
''')
|
226
|
+
# backward compatibility, since 0.2.1
|
227
|
+
async with self.conn.execute("SELECT * FROM user") as cursor:
|
228
|
+
res = await cursor.fetchall()
|
229
|
+
for r in res:
|
230
|
+
async with self.conn.execute("SELECT user_id FROM usize WHERE user_id = ?", (r[0], )) as cursor:
|
231
|
+
size = await cursor.fetchone()
|
232
|
+
if size is None:
|
233
|
+
async with self.conn.execute("SELECT SUM(file_size) FROM fmeta WHERE owner_id = ?", (r[0], )) as cursor:
|
234
|
+
size = await cursor.fetchone()
|
235
|
+
if size is not None and size[0] is not None:
|
236
|
+
await self.user_size_inc(r[0], size[0])
|
237
|
+
|
199
238
|
return self
|
200
239
|
|
201
240
|
async def get_file_record(self, url: str) -> Optional[FileDBRecord]:
|
@@ -291,6 +330,19 @@ class FileConn(DBConnBase):
|
|
291
330
|
|
292
331
|
return (dirs, files)
|
293
332
|
|
333
|
+
async def user_size(self, user_id: int) -> int:
|
334
|
+
async with self.conn.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, )) as cursor:
|
335
|
+
res = await cursor.fetchone()
|
336
|
+
if res is None:
|
337
|
+
return -1
|
338
|
+
return res[0]
|
339
|
+
async def user_size_inc(self, user_id: int, inc: int):
|
340
|
+
self.logger.debug(f"Increasing user {user_id} size by {inc}")
|
341
|
+
await self.conn.execute("INSERT OR REPLACE INTO usize (user_id, size) VALUES (?, COALESCE((SELECT size FROM usize WHERE user_id = ?), 0) + ?)", (user_id, user_id, inc))
|
342
|
+
async def user_size_dec(self, user_id: int, dec: int):
|
343
|
+
self.logger.debug(f"Decreasing user {user_id} size by {dec}")
|
344
|
+
await self.conn.execute("INSERT OR REPLACE INTO usize (user_id, size) VALUES (?, COALESCE((SELECT size FROM usize WHERE user_id = ?), 0) - ?)", (user_id, user_id, dec))
|
345
|
+
|
294
346
|
async def path_size(self, url: str, include_subpath = False) -> int:
|
295
347
|
if not url.endswith('/'):
|
296
348
|
url += '/'
|
@@ -303,24 +355,35 @@ class FileConn(DBConnBase):
|
|
303
355
|
assert res is not None
|
304
356
|
return res[0] or 0
|
305
357
|
|
306
|
-
async def set_file_record(
|
358
|
+
async def set_file_record(
|
359
|
+
self, url: str,
|
360
|
+
owner_id: Optional[int] = None,
|
361
|
+
file_id: Optional[str] = None,
|
362
|
+
file_size: Optional[int] = None,
|
363
|
+
permission: Optional[ FileReadPermission ] = None
|
364
|
+
):
|
307
365
|
self.logger.debug(f"Updating fmeta {url}: user_id={owner_id}, file_id={file_id}")
|
308
366
|
|
309
367
|
old = await self.get_file_record(url)
|
310
368
|
if old is not None:
|
311
|
-
|
312
|
-
|
313
|
-
|
369
|
+
# should delete the old blob if file_id is changed
|
370
|
+
assert file_id is None, "Cannot update file id"
|
371
|
+
assert file_size is None, "Cannot update file size"
|
372
|
+
|
373
|
+
if owner_id is None: owner_id = old.owner_id
|
374
|
+
if permission is None: permission = old.permission
|
314
375
|
await self.conn.execute(
|
315
376
|
"""
|
316
|
-
UPDATE fmeta SET file_id = ?, file_size = ?, permission = ?,
|
377
|
+
UPDATE fmeta SET owner_id = ?, file_id = ?, file_size = ?, permission = ?,
|
317
378
|
access_time = CURRENT_TIMESTAMP WHERE url = ?
|
318
|
-
""", (file_id, file_size, permission, url))
|
379
|
+
""", (owner_id, file_id, file_size, permission, url))
|
319
380
|
self.logger.info(f"File {url} updated")
|
320
381
|
else:
|
321
382
|
if permission is None:
|
322
|
-
permission = FileReadPermission.
|
383
|
+
permission = FileReadPermission.UNSET
|
384
|
+
assert owner_id is not None and file_id is not None and file_size is not None, "Missing required fields"
|
323
385
|
await self.conn.execute("INSERT INTO fmeta (url, owner_id, file_id, file_size, permission) VALUES (?, ?, ?, ?, ?)", (url, owner_id, file_id, file_size, permission))
|
386
|
+
await self.user_size_inc(owner_id, file_size)
|
324
387
|
self.logger.info(f"File {url} created")
|
325
388
|
|
326
389
|
async def log_access(self, url: str):
|
@@ -330,24 +393,35 @@ class FileConn(DBConnBase):
|
|
330
393
|
file_record = await self.get_file_record(url)
|
331
394
|
if file_record is None: return
|
332
395
|
await self.conn.execute("DELETE FROM fmeta WHERE url = ?", (url, ))
|
396
|
+
await self.user_size_dec(file_record.owner_id, file_record.file_size)
|
333
397
|
self.logger.info(f"Deleted fmeta {url}")
|
334
398
|
|
335
399
|
async def delete_user_file_records(self, owner_id: int):
|
336
400
|
async with self.conn.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, )) as cursor:
|
337
401
|
res = await cursor.fetchall()
|
338
402
|
await self.conn.execute("DELETE FROM fmeta WHERE owner_id = ?", (owner_id, ))
|
403
|
+
await self.conn.execute("DELETE FROM usize WHERE user_id = ?", (owner_id, ))
|
339
404
|
self.logger.info(f"Deleted {len(res)} files for user {owner_id}") # type: ignore
|
340
405
|
|
341
406
|
async def delete_path_records(self, path: str):
|
342
407
|
"""Delete all records with url starting with path"""
|
343
408
|
async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (path + '%', )) as cursor:
|
409
|
+
all_f_rec = await cursor.fetchall()
|
410
|
+
|
411
|
+
# update user size
|
412
|
+
async with self.conn.execute("SELECT DISTINCT owner_id FROM fmeta WHERE url LIKE ?", (path + '%', )) as cursor:
|
344
413
|
res = await cursor.fetchall()
|
414
|
+
for r in res:
|
415
|
+
async with self.conn.execute("SELECT SUM(file_size) FROM fmeta WHERE owner_id = ? AND url LIKE ?", (r[0], path + '%')) as cursor:
|
416
|
+
size = await cursor.fetchone()
|
417
|
+
if size is not None:
|
418
|
+
await self.user_size_dec(r[0], size[0])
|
419
|
+
|
345
420
|
await self.conn.execute("DELETE FROM fmeta WHERE url LIKE ?", (path + '%', ))
|
346
|
-
self.logger.info(f"Deleted {len(
|
421
|
+
self.logger.info(f"Deleted {len(all_f_rec)} files for path {path}") # type: ignore
|
347
422
|
|
348
|
-
async def set_file_blob(self, file_id: str, blob: bytes)
|
423
|
+
async def set_file_blob(self, file_id: str, blob: bytes):
|
349
424
|
await self.conn.execute("INSERT OR REPLACE INTO fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
|
350
|
-
return len(blob)
|
351
425
|
|
352
426
|
async def get_file_blob(self, file_id: str) -> Optional[bytes]:
|
353
427
|
async with self.conn.execute("SELECT data FROM fdata WHERE file_id = ?", (file_id, )) as cursor:
|
@@ -402,8 +476,9 @@ class Database:
|
|
402
476
|
logger = get_logger('database', global_instance=True)
|
403
477
|
|
404
478
|
async def init(self):
|
405
|
-
|
406
|
-
|
479
|
+
async with transaction(self):
|
480
|
+
await self.user.init()
|
481
|
+
await self.file.init()
|
407
482
|
return self
|
408
483
|
|
409
484
|
async def commit(self):
|
@@ -435,14 +510,20 @@ class Database:
|
|
435
510
|
first_component = url.split('/')[0]
|
436
511
|
if first_component != user.username:
|
437
512
|
if not user.is_admin:
|
438
|
-
raise
|
513
|
+
raise PermissionDeniedError(f"Permission denied: {user.username} cannot write to {url}")
|
439
514
|
else:
|
440
515
|
if await get_user(self, first_component) is None:
|
441
|
-
raise
|
516
|
+
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
517
|
+
|
518
|
+
# check if fize_size is within limit
|
519
|
+
file_size = len(blob)
|
520
|
+
user_size_used = await self.file.user_size(user.id)
|
521
|
+
if user_size_used + file_size > user.max_storage:
|
522
|
+
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
442
523
|
|
443
524
|
f_id = uuid.uuid4().hex
|
444
525
|
async with transaction(self):
|
445
|
-
|
526
|
+
await self.file.set_file_blob(f_id, blob)
|
446
527
|
await self.file.set_file_record(url, owner_id=user.id, file_id=f_id, file_size=file_size)
|
447
528
|
await self.user.set_active(user.username)
|
448
529
|
|
@@ -520,4 +601,32 @@ class Database:
|
|
520
601
|
zf.writestr(rel_path, blob)
|
521
602
|
|
522
603
|
buffer.seek(0)
|
523
|
-
return buffer
|
604
|
+
return buffer
|
605
|
+
|
606
|
+
def check_user_permission(user: DBUserRecord, owner: DBUserRecord, file: FileDBRecord) -> tuple[bool, str]:
|
607
|
+
if user.is_admin:
|
608
|
+
return True, ""
|
609
|
+
|
610
|
+
# check permission of the file
|
611
|
+
if file.permission == FileReadPermission.PRIVATE:
|
612
|
+
if user.id != owner.id:
|
613
|
+
return False, "Permission denied, private file"
|
614
|
+
elif file.permission == FileReadPermission.PROTECTED:
|
615
|
+
if user.id == 0:
|
616
|
+
return False, "Permission denied, protected file"
|
617
|
+
elif file.permission == FileReadPermission.PUBLIC:
|
618
|
+
return True, ""
|
619
|
+
else:
|
620
|
+
assert file.permission == FileReadPermission.UNSET
|
621
|
+
|
622
|
+
# use owner's permission as fallback
|
623
|
+
if owner.permission == FileReadPermission.PRIVATE:
|
624
|
+
if user.id != owner.id:
|
625
|
+
return False, "Permission denied, private user file"
|
626
|
+
elif owner.permission == FileReadPermission.PROTECTED:
|
627
|
+
if user.id == 0:
|
628
|
+
return False, "Permission denied, protected user file"
|
629
|
+
else:
|
630
|
+
assert owner.permission == FileReadPermission.PUBLIC or owner.permission == FileReadPermission.UNSET
|
631
|
+
|
632
|
+
return True, ""
|
lfss/src/error.py
ADDED
lfss/src/server.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
from typing import Optional
|
2
|
+
from functools import wraps
|
2
3
|
|
3
4
|
from fastapi import FastAPI, APIRouter, Depends, Request, Response
|
4
5
|
from fastapi.exceptions import HTTPException
|
@@ -10,10 +11,11 @@ import json
|
|
10
11
|
import mimetypes
|
11
12
|
from contextlib import asynccontextmanager
|
12
13
|
|
14
|
+
from .error import *
|
13
15
|
from .log import get_logger
|
14
16
|
from .config import MAX_BUNDLE_BYTES
|
15
17
|
from .utils import ensure_uri_compnents
|
16
|
-
from .database import Database, DBUserRecord, DECOY_USER, FileReadPermission
|
18
|
+
from .database import Database, DBUserRecord, DECOY_USER, FileDBRecord, check_user_permission, FileReadPermission
|
17
19
|
|
18
20
|
logger = get_logger("server")
|
19
21
|
conn = Database()
|
@@ -23,12 +25,40 @@ async def lifespan(app: FastAPI):
|
|
23
25
|
global conn
|
24
26
|
await conn.init()
|
25
27
|
yield
|
28
|
+
await conn.commit()
|
26
29
|
await conn.close()
|
27
30
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
31
|
+
def handle_exception(fn):
|
32
|
+
@wraps(fn)
|
33
|
+
async def wrapper(*args, **kwargs):
|
34
|
+
try:
|
35
|
+
return await fn(*args, **kwargs)
|
36
|
+
except Exception as e:
|
37
|
+
logger.error(f"Error in {fn.__name__}: {e}")
|
38
|
+
if isinstance(e, HTTPException): raise e
|
39
|
+
elif isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
|
40
|
+
elif isinstance(e, PermissionDeniedError): raise HTTPException(status_code=403, detail=str(e))
|
41
|
+
else: raise HTTPException(status_code=500, detail=str(e))
|
42
|
+
return wrapper
|
43
|
+
|
44
|
+
async def get_credential_from_params(request: Request):
|
45
|
+
return request.query_params.get("token")
|
46
|
+
async def get_current_user(
|
47
|
+
token: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)),
|
48
|
+
q_token: Optional[str] = Depends(get_credential_from_params)
|
49
|
+
):
|
50
|
+
"""
|
51
|
+
First try to get the user from the bearer token,
|
52
|
+
if not found, try to get the user from the query parameter
|
53
|
+
"""
|
54
|
+
if token:
|
55
|
+
user = await conn.user.get_user_by_credential(token.credentials)
|
56
|
+
else:
|
57
|
+
if not q_token:
|
58
|
+
return DECOY_USER
|
59
|
+
else:
|
60
|
+
user = await conn.user.get_user_by_credential(q_token)
|
61
|
+
|
32
62
|
if not user:
|
33
63
|
raise HTTPException(status_code=401, detail="Invalid token")
|
34
64
|
return user
|
@@ -47,6 +77,8 @@ router_fs = APIRouter(prefix="")
|
|
47
77
|
@router_fs.get("/{path:path}")
|
48
78
|
async def get_file(path: str, asfile = False, user: DBUserRecord = Depends(get_current_user)):
|
49
79
|
path = ensure_uri_compnents(path)
|
80
|
+
|
81
|
+
# handle directory query
|
50
82
|
if path == "": path = "/"
|
51
83
|
if path.endswith("/"):
|
52
84
|
# return file under the path as json
|
@@ -67,23 +99,16 @@ async def get_file(path: str, asfile = False, user: DBUserRecord = Depends(get_c
|
|
67
99
|
"dirs": dirs,
|
68
100
|
"files": files
|
69
101
|
}
|
70
|
-
|
102
|
+
|
71
103
|
file_record = await conn.file.get_file_record(path)
|
72
104
|
if not file_record:
|
73
105
|
raise HTTPException(status_code=404, detail="File not found")
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
else:
|
81
|
-
assert path.startswith(f"{user.username}/")
|
82
|
-
elif perm == FileReadPermission.PROTECTED:
|
83
|
-
if user.id == 0:
|
84
|
-
raise HTTPException(status_code=403, detail="Permission denied")
|
85
|
-
else:
|
86
|
-
assert perm == FileReadPermission.PUBLIC
|
106
|
+
|
107
|
+
owner = await conn.user.get_user_by_id(file_record.owner_id)
|
108
|
+
assert owner is not None, "Owner not found"
|
109
|
+
allow_access, reason = check_user_permission(user, owner, file_record)
|
110
|
+
if not allow_access:
|
111
|
+
raise HTTPException(status_code=403, detail=reason)
|
87
112
|
|
88
113
|
fname = path.split("/")[-1]
|
89
114
|
async def send(media_type: Optional[str] = None, disposition = "attachment"):
|
@@ -110,7 +135,7 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
|
|
110
135
|
path = ensure_uri_compnents(path)
|
111
136
|
if user.id == 0:
|
112
137
|
logger.debug("Reject put request from DECOY_USER")
|
113
|
-
raise HTTPException(status_code=
|
138
|
+
raise HTTPException(status_code=401, detail="Permission denied")
|
114
139
|
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
115
140
|
logger.debug(f"Reject put request from {user.username} to {path}")
|
116
141
|
raise HTTPException(status_code=403, detail="Permission denied")
|
@@ -128,20 +153,20 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
|
|
128
153
|
logger.debug(f"Content-Type: {content_type}")
|
129
154
|
if content_type == "application/json":
|
130
155
|
body = await request.json()
|
131
|
-
await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'))
|
156
|
+
await handle_exception(conn.save_file)(user.id, path, json.dumps(body).encode('utf-8'))
|
132
157
|
elif content_type == "application/x-www-form-urlencoded":
|
133
158
|
# may not work...
|
134
159
|
body = await request.form()
|
135
160
|
file = body.get("file")
|
136
161
|
if isinstance(file, str) or file is None:
|
137
162
|
raise HTTPException(status_code=400, detail="Invalid form data, file required")
|
138
|
-
await conn.save_file(user.id, path, await file.read())
|
163
|
+
await handle_exception(conn.save_file)(user.id, path, await file.read())
|
139
164
|
elif content_type == "application/octet-stream":
|
140
165
|
body = await request.body()
|
141
|
-
await conn.save_file(user.id, path, body)
|
166
|
+
await handle_exception(conn.save_file)(user.id, path, body)
|
142
167
|
else:
|
143
168
|
body = await request.body()
|
144
|
-
await conn.save_file(user.id, path, body)
|
169
|
+
await handle_exception(conn.save_file)(user.id, path, body)
|
145
170
|
|
146
171
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
|
147
172
|
if exists_flag:
|
@@ -157,7 +182,7 @@ async def put_file(request: Request, path: str, user: DBUserRecord = Depends(get
|
|
157
182
|
async def delete_file(path: str, user: DBUserRecord = Depends(get_current_user)):
|
158
183
|
path = ensure_uri_compnents(path)
|
159
184
|
if user.id == 0:
|
160
|
-
raise HTTPException(status_code=
|
185
|
+
raise HTTPException(status_code=401, detail="Permission denied")
|
161
186
|
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
162
187
|
raise HTTPException(status_code=403, detail="Permission denied")
|
163
188
|
|
@@ -184,12 +209,24 @@ async def bundle_files(path: str, user: DBUserRecord = Depends(get_current_user)
|
|
184
209
|
if not path == "" and path[0] == "/": # adapt to both /path and path
|
185
210
|
path = path[1:]
|
186
211
|
|
187
|
-
#
|
188
|
-
|
189
|
-
|
212
|
+
owner_records_cache = {} # cache owner records, ID -> UserRecord
|
213
|
+
async def is_access_granted(file_record: FileDBRecord):
|
214
|
+
owner_id = file_record.owner_id
|
215
|
+
owner = owner_records_cache.get(owner_id, None)
|
216
|
+
if owner is None:
|
217
|
+
owner = await conn.user.get_user_by_id(owner_id)
|
218
|
+
assert owner is not None, f"File owner not found: id={owner_id}"
|
219
|
+
owner_records_cache[owner_id] = owner
|
220
|
+
|
221
|
+
allow_access, _ = check_user_permission(user, owner, file_record)
|
222
|
+
return allow_access
|
223
|
+
|
190
224
|
files = await conn.file.list_path(path, flat = True)
|
225
|
+
files = [f for f in files if await is_access_granted(f)]
|
191
226
|
if len(files) == 0:
|
192
227
|
raise HTTPException(status_code=404, detail="No files found")
|
228
|
+
|
229
|
+
# return bundle of files
|
193
230
|
total_size = sum([f.file_size for f in files])
|
194
231
|
if total_size > MAX_BUNDLE_BYTES:
|
195
232
|
raise HTTPException(status_code=400, detail="Too large to zip")
|
@@ -214,6 +251,40 @@ async def get_file_meta(path: str, user: DBUserRecord = Depends(get_current_user
|
|
214
251
|
raise HTTPException(status_code=404, detail="File not found")
|
215
252
|
return file_record
|
216
253
|
|
254
|
+
@router_api.post("/fmeta")
|
255
|
+
async def update_file_meta(
|
256
|
+
path: str,
|
257
|
+
perm: Optional[int] = None,
|
258
|
+
user: DBUserRecord = Depends(get_current_user)
|
259
|
+
):
|
260
|
+
if user.id == 0:
|
261
|
+
raise HTTPException(status_code=401, detail="Permission denied")
|
262
|
+
path = ensure_uri_compnents(path)
|
263
|
+
file_record = await conn.file.get_file_record(path)
|
264
|
+
if not file_record:
|
265
|
+
logger.debug(f"Reject update meta request from {user.username} to {path}")
|
266
|
+
raise HTTPException(status_code=404, detail="File not found")
|
267
|
+
|
268
|
+
if not (user.is_admin or user.id == file_record.owner_id):
|
269
|
+
logger.debug(f"Reject update meta request from {user.username} to {path}")
|
270
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
271
|
+
|
272
|
+
if perm is not None:
|
273
|
+
logger.info(f"Update permission of {path} to {perm}")
|
274
|
+
await conn.file.set_file_record(
|
275
|
+
url = file_record.url,
|
276
|
+
permission = FileReadPermission(perm)
|
277
|
+
)
|
278
|
+
return Response(status_code=200, content="OK")
|
279
|
+
|
280
|
+
@router_api.get("/whoami")
|
281
|
+
async def whoami(user: DBUserRecord = Depends(get_current_user)):
|
282
|
+
if user.id == 0:
|
283
|
+
raise HTTPException(status_code=401, detail="Login required")
|
284
|
+
user.credential = "__HIDDEN__"
|
285
|
+
return user
|
286
|
+
|
287
|
+
|
217
288
|
# order matters
|
218
289
|
app.include_router(router_api)
|
219
290
|
app.include_router(router_fs)
|