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.
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.set_user(user.username, args.password, args.admin)
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(self, username: str, password: str, is_admin: bool = False) -> int:
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 set_user(self, username: str, password: Optional[str] = None, is_admin: Optional[bool] = None):
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
- async with self.conn.execute("SELECT credential FROM user WHERE username = ?", (username, )) as cursor:
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
- async with self.conn.execute("SELECT is_admin FROM user WHERE username = ?", (username, )) as cursor:
123
- res = await cursor.fetchone()
124
- assert res is not None, f"User {username} not found"
125
- is_admin = res[0]
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(self, url: str, owner_id: int, file_id: str, file_size: int, permission: Optional[ FileReadPermission ] = None):
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
- assert old.owner_id == owner_id, f"User mismatch: {old.owner_id} != {owner_id}"
312
- if permission is None:
313
- permission = old.permission
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.PUBLIC
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(res)} files for path {path}") # type: ignore
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) -> int:
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
- await self.user.init()
406
- await self.file.init()
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 ValueError(f"Permission denied: {user.username} cannot write to {url}")
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 ValueError(f"Invalid path: {first_component} is not a valid username")
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
- file_size = await self.file.set_file_blob(f_id, blob)
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
@@ -0,0 +1,6 @@
1
+
2
+ class LFSSExceptionBase(Exception):...
3
+
4
+ class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
5
+
6
+ class StorageExceededError(LFSSExceptionBase):...
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
- async def get_current_user(token: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
29
- if not token:
30
- return DECOY_USER
31
- user = await conn.user.get_user_by_credential(token.credentials)
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
- # permission check
76
- perm = file_record.permission
77
- if perm == FileReadPermission.PRIVATE:
78
- if not user.is_admin and user.id != file_record.owner_id:
79
- raise HTTPException(status_code=403, detail="Permission denied")
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=403, detail="Permission denied")
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=403, detail="Permission denied")
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
- # TODO: maybe check permission here...
188
-
189
- # return bundle of files
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)