lfss 0.1.0__py3-none-any.whl → 0.2.3__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.
frontend/utils.js ADDED
@@ -0,0 +1,83 @@
1
+
2
+ export function formatSize(size){
3
+ const sizeInKb = size / 1024;
4
+ const sizeInMb = sizeInKb / 1024;
5
+ const sizeInGb = sizeInMb / 1024;
6
+ if (sizeInGb > 1){
7
+ return sizeInGb.toFixed(2) + ' GB';
8
+ }
9
+ else if (sizeInMb > 1){
10
+ return sizeInMb.toFixed(2) + ' MB';
11
+ }
12
+ else if (sizeInKb > 1){
13
+ return sizeInKb.toFixed(2) + ' KB';
14
+ }
15
+ else {
16
+ return size + ' B';
17
+ }
18
+ }
19
+
20
+ export function copyToClipboard(text){
21
+ function secureCopy(text){
22
+ navigator.clipboard.writeText(text);
23
+ }
24
+ function unsecureCopy(text){
25
+ const el = document.createElement('textarea');
26
+ el.value = text;
27
+ document.body.appendChild(el);
28
+ el.select();
29
+ document.execCommand('copy');
30
+ document.body.removeChild(el);
31
+ }
32
+ if (navigator.clipboard){
33
+ secureCopy(text);
34
+ }
35
+ else {
36
+ unsecureCopy(text);
37
+ }
38
+ }
39
+
40
+ export function encodePathURI(path){
41
+ return path.split('/').map(encodeURIComponent).join('/');
42
+ }
43
+
44
+ export function decodePathURI(path){
45
+ return path.split('/').map(decodeURIComponent).join('/');
46
+ }
47
+
48
+ export function ensurePathURI(path){
49
+ return encodePathURI(decodePathURI(path));
50
+ }
51
+
52
+ export function getRandomString(n, additionalCharset='0123456789_-(=)[]{}'){
53
+ let result = '';
54
+ let charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
55
+ const firstChar = charset[Math.floor(Math.random() * charset.length)];
56
+ const lastChar = charset[Math.floor(Math.random() * charset.length)];
57
+ result += firstChar;
58
+ charset += additionalCharset;
59
+ for (let i = 0; i < n-2; i++){
60
+ result += charset[Math.floor(Math.random() * charset.length)];
61
+ }
62
+ result += lastChar;
63
+ return result;
64
+ };
65
+
66
+ /**
67
+ * @param {string} dateStr
68
+ * @returns {string}
69
+ */
70
+ export function cvtGMT2Local(dateStr){
71
+ const gmtdate = new Date(dateStr);
72
+ const localdate = new Date(gmtdate.getTime() + gmtdate.getTimezoneOffset() * 60000);
73
+ return localdate.toISOString().slice(0, 19).replace('T', ' ');
74
+ }
75
+
76
+ export function debounce(fn,wait){
77
+ let timeout;
78
+ return function(...args){
79
+ const context = this;
80
+ if (timeout) clearTimeout(timeout);
81
+ timeout = setTimeout(() => fn.apply(context, args), wait);
82
+ }
83
+ }
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
@@ -5,6 +5,7 @@ from abc import ABC, abstractmethod
5
5
  import urllib.parse
6
6
  import dataclasses, hashlib, uuid
7
7
  from contextlib import asynccontextmanager
8
+ from functools import wraps
8
9
  from enum import IntEnum
9
10
  import zipfile, io
10
11
 
@@ -14,12 +15,25 @@ from asyncio import Lock
14
15
  from .config import DATA_HOME
15
16
  from .log import get_logger
16
17
  from .utils import decode_uri_compnents
18
+ from .error import *
17
19
 
18
20
  _g_conn: Optional[aiosqlite.Connection] = None
19
21
 
20
22
  def hash_credential(username, password):
21
23
  return hashlib.sha256((username + password).encode()).hexdigest()
22
24
 
25
+ _atomic_lock = Lock()
26
+ def atomic(func):
27
+ """
28
+ Ensure non-reentrancy.
29
+ Can be skipped if the function only executes a single SQL statement.
30
+ """
31
+ @wraps(func)
32
+ async def wrapper(*args, **kwargs):
33
+ async with _atomic_lock:
34
+ return await func(*args, **kwargs)
35
+ return wrapper
36
+
23
37
  class DBConnBase(ABC):
24
38
  logger = get_logger('database', global_instance=True)
25
39
 
@@ -40,6 +54,12 @@ class DBConnBase(ABC):
40
54
  async def commit(self):
41
55
  await self.conn.commit()
42
56
 
57
+ class FileReadPermission(IntEnum):
58
+ UNSET = 0 # not set
59
+ PUBLIC = 1 # accessible by anyone
60
+ PROTECTED = 2 # accessible by any user
61
+ PRIVATE = 3 # accessible by owner only (including admin)
62
+
43
63
  @dataclasses.dataclass
44
64
  class DBUserRecord:
45
65
  id: int
@@ -48,11 +68,13 @@ class DBUserRecord:
48
68
  is_admin: bool
49
69
  create_time: str
50
70
  last_active: str
71
+ max_storage: int
72
+ permission: 'FileReadPermission'
51
73
 
52
74
  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})"
75
+ 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
76
 
55
- DECOY_USER = DBUserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00')
77
+ DECOY_USER = DBUserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
56
78
  class UserConn(DBConnBase):
57
79
 
58
80
  @staticmethod
@@ -61,6 +83,7 @@ class UserConn(DBConnBase):
61
83
 
62
84
  async def init(self):
63
85
  await super().init()
86
+ # default to 1GB (1024x1024x1024 bytes)
64
87
  await self.conn.execute('''
65
88
  CREATE TABLE IF NOT EXISTS user (
66
89
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -68,9 +91,18 @@ class UserConn(DBConnBase):
68
91
  credential VARCHAR(255) NOT NULL,
69
92
  is_admin BOOLEAN DEFAULT FALSE,
70
93
  create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
71
- last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
94
+ last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
95
+ max_storage INTEGER DEFAULT 1073741824,
96
+ permission INTEGER DEFAULT 0
72
97
  )
73
98
  ''')
99
+ await self.conn.execute('''
100
+ CREATE INDEX IF NOT EXISTS idx_user_username ON user(username)
101
+ ''')
102
+ await self.conn.execute('''
103
+ CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential)
104
+ ''')
105
+
74
106
  return self
75
107
 
76
108
  async def get_user(self, username: str) -> Optional[DBUserRecord]:
@@ -94,37 +126,48 @@ class UserConn(DBConnBase):
94
126
  if res is None: return None
95
127
  return self.parse_record(res)
96
128
 
97
- async def create_user(self, username: str, password: str, is_admin: bool = False) -> int:
129
+ @atomic
130
+ async def create_user(
131
+ self, username: str, password: str, is_admin: bool = False,
132
+ max_storage: int = 1073741824, permission: FileReadPermission = FileReadPermission.UNSET
133
+ ) -> int:
98
134
  assert not username.startswith('_'), "Error: reserved username"
99
135
  assert not ('/' in username or len(username) > 255), "Invalid username"
100
136
  assert urllib.parse.quote(username) == username, "Invalid username, must be URL safe"
101
137
  self.logger.debug(f"Creating user {username}")
102
138
  credential = hash_credential(username, password)
103
139
  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:
140
+ 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
141
  self.logger.info(f"User {username} created")
106
142
  assert cursor.lastrowid is not None
107
143
  return cursor.lastrowid
108
144
 
109
- async def set_user(self, username: str, password: Optional[str] = None, is_admin: Optional[bool] = None):
145
+ @atomic
146
+ async def update_user(
147
+ self, username: str, password: Optional[str] = None, is_admin: Optional[bool] = None,
148
+ max_storage: Optional[int] = None, permission: Optional[FileReadPermission] = None
149
+ ):
110
150
  assert not username.startswith('_'), "Error: reserved username"
111
151
  assert not ('/' in username or len(username) > 255), "Invalid username"
112
152
  assert urllib.parse.quote(username) == username, "Invalid username, must be URL safe"
153
+
154
+ current_record = await self.get_user(username)
155
+ if current_record is None:
156
+ raise ValueError(f"User {username} not found")
157
+
113
158
  if password is not None:
114
159
  credential = hash_credential(username, password)
115
160
  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]
161
+ credential = current_record.credential
120
162
 
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))
163
+ if is_admin is None: is_admin = current_record.is_admin
164
+ if max_storage is None: max_storage = current_record.max_storage
165
+ if permission is None: permission = current_record.permission
166
+
167
+ await self.conn.execute(
168
+ "UPDATE user SET credential = ?, is_admin = ?, max_storage = ?, permission = ? WHERE username = ?",
169
+ (credential, is_admin, max_storage, int(permission), username)
170
+ )
128
171
  self.logger.info(f"User {username} updated")
129
172
 
130
173
  async def all(self):
@@ -139,11 +182,6 @@ class UserConn(DBConnBase):
139
182
  await self.conn.execute("DELETE FROM user WHERE username = ?", (username, ))
140
183
  self.logger.info(f"Delete user {username}")
141
184
 
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
185
  @dataclasses.dataclass
148
186
  class FileDBRecord:
149
187
  url: str
@@ -196,6 +234,25 @@ class FileConn(DBConnBase):
196
234
  )
197
235
  ''')
198
236
 
237
+ # user file size table
238
+ await self.conn.execute('''
239
+ CREATE TABLE IF NOT EXISTS usize (
240
+ user_id INTEGER PRIMARY KEY,
241
+ size INTEGER DEFAULT 0
242
+ )
243
+ ''')
244
+ # backward compatibility, since 0.2.1
245
+ async with self.conn.execute("SELECT * FROM user") as cursor:
246
+ res = await cursor.fetchall()
247
+ for r in res:
248
+ async with self.conn.execute("SELECT user_id FROM usize WHERE user_id = ?", (r[0], )) as cursor:
249
+ size = await cursor.fetchone()
250
+ if size is None:
251
+ async with self.conn.execute("SELECT SUM(file_size) FROM fmeta WHERE owner_id = ?", (r[0], )) as cursor:
252
+ size = await cursor.fetchone()
253
+ if size is not None and size[0] is not None:
254
+ await self.user_size_inc(r[0], size[0])
255
+
199
256
  return self
200
257
 
201
258
  async def get_file_record(self, url: str) -> Optional[FileDBRecord]:
@@ -291,6 +348,19 @@ class FileConn(DBConnBase):
291
348
 
292
349
  return (dirs, files)
293
350
 
351
+ async def user_size(self, user_id: int) -> int:
352
+ async with self.conn.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, )) as cursor:
353
+ res = await cursor.fetchone()
354
+ if res is None:
355
+ return -1
356
+ return res[0]
357
+ async def user_size_inc(self, user_id: int, inc: int):
358
+ self.logger.debug(f"Increasing user {user_id} size by {inc}")
359
+ await self.conn.execute("INSERT OR REPLACE INTO usize (user_id, size) VALUES (?, COALESCE((SELECT size FROM usize WHERE user_id = ?), 0) + ?)", (user_id, user_id, inc))
360
+ async def user_size_dec(self, user_id: int, dec: int):
361
+ self.logger.debug(f"Decreasing user {user_id} size by {dec}")
362
+ await self.conn.execute("INSERT OR REPLACE INTO usize (user_id, size) VALUES (?, COALESCE((SELECT size FROM usize WHERE user_id = ?), 0) - ?)", (user_id, user_id, dec))
363
+
294
364
  async def path_size(self, url: str, include_subpath = False) -> int:
295
365
  if not url.endswith('/'):
296
366
  url += '/'
@@ -303,51 +373,92 @@ class FileConn(DBConnBase):
303
373
  assert res is not None
304
374
  return res[0] or 0
305
375
 
306
- async def set_file_record(self, url: str, owner_id: int, file_id: str, file_size: int, permission: Optional[ FileReadPermission ] = None):
307
- self.logger.debug(f"Updating fmeta {url}: user_id={owner_id}, file_id={file_id}")
376
+ @atomic
377
+ async def set_file_record(
378
+ self, url: str,
379
+ owner_id: Optional[int] = None,
380
+ file_id: Optional[str] = None,
381
+ file_size: Optional[int] = None,
382
+ permission: Optional[ FileReadPermission ] = None
383
+ ):
308
384
 
309
385
  old = await self.get_file_record(url)
310
386
  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
387
+ self.logger.debug(f"Updating fmeta {url}: permission={permission}, owner_id={owner_id}")
388
+ # should delete the old blob if file_id is changed
389
+ assert file_id is None, "Cannot update file id"
390
+ assert file_size is None, "Cannot update file size"
391
+
392
+ if owner_id is None: owner_id = old.owner_id
393
+ if permission is None: permission = old.permission
314
394
  await self.conn.execute(
315
395
  """
316
- UPDATE fmeta SET file_id = ?, file_size = ?, permission = ?,
396
+ UPDATE fmeta SET owner_id = ?, permission = ?,
317
397
  access_time = CURRENT_TIMESTAMP WHERE url = ?
318
- """, (file_id, file_size, permission, url))
398
+ """, (owner_id, int(permission), url))
319
399
  self.logger.info(f"File {url} updated")
320
400
  else:
401
+ self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}")
321
402
  if permission is None:
322
- permission = FileReadPermission.PUBLIC
323
- await self.conn.execute("INSERT INTO fmeta (url, owner_id, file_id, file_size, permission) VALUES (?, ?, ?, ?, ?)", (url, owner_id, file_id, file_size, permission))
403
+ permission = FileReadPermission.UNSET
404
+ assert owner_id is not None and file_id is not None and file_size is not None, "Missing required fields"
405
+ await self.conn.execute(
406
+ "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission) VALUES (?, ?, ?, ?, ?)",
407
+ (url, owner_id, file_id, file_size, int(permission))
408
+ )
409
+ await self.user_size_inc(owner_id, file_size)
324
410
  self.logger.info(f"File {url} created")
325
411
 
412
+ @atomic
413
+ async def move_file(self, old_url: str, new_url: str):
414
+ old = await self.get_file_record(old_url)
415
+ if old is None:
416
+ raise FileNotFoundError(f"File {old_url} not found")
417
+ new_exists = await self.get_file_record(new_url)
418
+ if new_exists is not None:
419
+ raise FileExistsError(f"File {new_url} already exists")
420
+ async with self.conn.execute("UPDATE fmeta SET url = ? WHERE url = ?", (new_url, old_url)):
421
+ self.logger.info(f"Moved file {old_url} to {new_url}")
422
+
326
423
  async def log_access(self, url: str):
327
424
  await self.conn.execute("UPDATE fmeta SET access_time = CURRENT_TIMESTAMP WHERE url = ?", (url, ))
328
425
 
426
+ @atomic
329
427
  async def delete_file_record(self, url: str):
330
428
  file_record = await self.get_file_record(url)
331
429
  if file_record is None: return
332
430
  await self.conn.execute("DELETE FROM fmeta WHERE url = ?", (url, ))
431
+ await self.user_size_dec(file_record.owner_id, file_record.file_size)
333
432
  self.logger.info(f"Deleted fmeta {url}")
334
433
 
434
+ @atomic
335
435
  async def delete_user_file_records(self, owner_id: int):
336
436
  async with self.conn.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, )) as cursor:
337
437
  res = await cursor.fetchall()
338
438
  await self.conn.execute("DELETE FROM fmeta WHERE owner_id = ?", (owner_id, ))
439
+ await self.conn.execute("DELETE FROM usize WHERE user_id = ?", (owner_id, ))
339
440
  self.logger.info(f"Deleted {len(res)} files for user {owner_id}") # type: ignore
340
441
 
442
+ @atomic
341
443
  async def delete_path_records(self, path: str):
342
444
  """Delete all records with url starting with path"""
343
445
  async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (path + '%', )) as cursor:
446
+ all_f_rec = await cursor.fetchall()
447
+
448
+ # update user size
449
+ async with self.conn.execute("SELECT DISTINCT owner_id FROM fmeta WHERE url LIKE ?", (path + '%', )) as cursor:
344
450
  res = await cursor.fetchall()
451
+ for r in res:
452
+ async with self.conn.execute("SELECT SUM(file_size) FROM fmeta WHERE owner_id = ? AND url LIKE ?", (r[0], path + '%')) as cursor:
453
+ size = await cursor.fetchone()
454
+ if size is not None:
455
+ await self.user_size_dec(r[0], size[0])
456
+
345
457
  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
458
+ self.logger.info(f"Deleted {len(all_f_rec)} files for path {path}") # type: ignore
347
459
 
348
- async def set_file_blob(self, file_id: str, blob: bytes) -> int:
460
+ async def set_file_blob(self, file_id: str, blob: bytes):
349
461
  await self.conn.execute("INSERT OR REPLACE INTO fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
350
- return len(blob)
351
462
 
352
463
  async def get_file_blob(self, file_id: str) -> Optional[bytes]:
353
464
  async with self.conn.execute("SELECT data FROM fdata WHERE file_id = ?", (file_id, )) as cursor:
@@ -362,18 +473,20 @@ class FileConn(DBConnBase):
362
473
  async def delete_file_blobs(self, file_ids: list[str]):
363
474
  await self.conn.execute("DELETE FROM fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
364
475
 
365
- def _validate_url(url: str, is_file = True) -> bool:
476
+ def validate_url(url: str, is_file = True):
366
477
  ret = not url.startswith('/') and not ('..' in url) and ('/' in url) and not ('//' in url) \
367
478
  and not ' ' in url and not url.startswith('\\') and not url.startswith('_') and not url.startswith('.')
368
479
 
369
480
  if not ret:
370
- return False
481
+ raise InvalidPathError(f"Invalid URL: {url}")
371
482
 
372
483
  if is_file:
373
484
  ret = ret and not url.endswith('/')
374
485
  else:
375
486
  ret = ret and url.endswith('/')
376
- return ret
487
+
488
+ if not ret:
489
+ raise InvalidPathError(f"Invalid URL: {url}")
377
490
 
378
491
  async def get_user(db: "Database", user: int | str) -> Optional[DBUserRecord]:
379
492
  if isinstance(user, str):
@@ -393,6 +506,7 @@ async def transaction(db: "Database"):
393
506
  except Exception as e:
394
507
  db.logger.error(f"Error in transaction: {e}")
395
508
  await db.rollback()
509
+ raise e
396
510
  finally:
397
511
  _transaction_lock.release()
398
512
 
@@ -402,8 +516,9 @@ class Database:
402
516
  logger = get_logger('database', global_instance=True)
403
517
 
404
518
  async def init(self):
405
- await self.user.init()
406
- await self.file.init()
519
+ async with transaction(self):
520
+ await self.user.init()
521
+ await self.file.init()
407
522
  return self
408
523
 
409
524
  async def commit(self):
@@ -421,8 +536,7 @@ class Database:
421
536
  await _g_conn.rollback()
422
537
 
423
538
  async def save_file(self, u: int | str, url: str, blob: bytes):
424
- if not _validate_url(url):
425
- raise ValueError(f"Invalid URL: {url}")
539
+ validate_url(url)
426
540
  assert isinstance(blob, bytes), "blob must be bytes"
427
541
 
428
542
  user = await get_user(self, u)
@@ -435,20 +549,26 @@ class Database:
435
549
  first_component = url.split('/')[0]
436
550
  if first_component != user.username:
437
551
  if not user.is_admin:
438
- raise ValueError(f"Permission denied: {user.username} cannot write to {url}")
552
+ raise PermissionDeniedError(f"Permission denied: {user.username} cannot write to {url}")
439
553
  else:
440
554
  if await get_user(self, first_component) is None:
441
- raise ValueError(f"Invalid path: {first_component} is not a valid username")
555
+ raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
556
+
557
+ # check if fize_size is within limit
558
+ file_size = len(blob)
559
+ user_size_used = await self.file.user_size(user.id)
560
+ if user_size_used + file_size > user.max_storage:
561
+ 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
562
 
443
563
  f_id = uuid.uuid4().hex
444
564
  async with transaction(self):
445
- file_size = await self.file.set_file_blob(f_id, blob)
565
+ await self.file.set_file_blob(f_id, blob)
446
566
  await self.file.set_file_record(url, owner_id=user.id, file_id=f_id, file_size=file_size)
447
567
  await self.user.set_active(user.username)
448
568
 
449
569
  # async def read_file_stream(self, url: str): ...
450
570
  async def read_file(self, url: str) -> bytes:
451
- if not _validate_url(url): raise ValueError(f"Invalid URL: {url}")
571
+ validate_url(url)
452
572
 
453
573
  r = await self.file.get_file_record(url)
454
574
  if r is None:
@@ -465,7 +585,7 @@ class Database:
465
585
  return blob
466
586
 
467
587
  async def delete_file(self, url: str) -> Optional[FileDBRecord]:
468
- if not _validate_url(url): raise ValueError(f"Invalid URL: {url}")
588
+ validate_url(url)
469
589
 
470
590
  async with transaction(self):
471
591
  r = await self.file.get_file_record(url)
@@ -475,9 +595,16 @@ class Database:
475
595
  await self.file.delete_file_blob(f_id)
476
596
  await self.file.delete_file_record(url)
477
597
  return r
598
+
599
+ async def move_file(self, old_url: str, new_url: str):
600
+ validate_url(old_url)
601
+ validate_url(new_url)
602
+
603
+ async with transaction(self):
604
+ await self.file.move_file(old_url, new_url)
478
605
 
479
606
  async def delete_path(self, url: str):
480
- if not _validate_url(url, is_file=False): raise ValueError(f"Invalid URL: {url}")
607
+ validate_url(url, is_file=False)
481
608
 
482
609
  async with transaction(self):
483
610
  records = await self.file.get_path_records(url)
@@ -520,4 +647,32 @@ class Database:
520
647
  zf.writestr(rel_path, blob)
521
648
 
522
649
  buffer.seek(0)
523
- return buffer
650
+ return buffer
651
+
652
+ def check_user_permission(user: DBUserRecord, owner: DBUserRecord, file: FileDBRecord) -> tuple[bool, str]:
653
+ if user.is_admin:
654
+ return True, ""
655
+
656
+ # check permission of the file
657
+ if file.permission == FileReadPermission.PRIVATE:
658
+ if user.id != owner.id:
659
+ return False, "Permission denied, private file"
660
+ elif file.permission == FileReadPermission.PROTECTED:
661
+ if user.id == 0:
662
+ return False, "Permission denied, protected file"
663
+ elif file.permission == FileReadPermission.PUBLIC:
664
+ return True, ""
665
+ else:
666
+ assert file.permission == FileReadPermission.UNSET
667
+
668
+ # use owner's permission as fallback
669
+ if owner.permission == FileReadPermission.PRIVATE:
670
+ if user.id != owner.id:
671
+ return False, "Permission denied, private user file"
672
+ elif owner.permission == FileReadPermission.PROTECTED:
673
+ if user.id == 0:
674
+ return False, "Permission denied, protected user file"
675
+ else:
676
+ assert owner.permission == FileReadPermission.PUBLIC or owner.permission == FileReadPermission.UNSET
677
+
678
+ return True, ""
lfss/src/error.py ADDED
@@ -0,0 +1,8 @@
1
+
2
+ class LFSSExceptionBase(Exception):...
3
+
4
+ class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
5
+
6
+ class InvalidPathError(LFSSExceptionBase, ValueError):...
7
+
8
+ class StorageExceededError(LFSSExceptionBase):...