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.
- Readme.md +28 -0
- docs/Known_issues.md +1 -0
- docs/Permission.md +29 -0
- frontend/api.js +204 -0
- frontend/index.html +60 -0
- frontend/popup.css +30 -0
- frontend/popup.js +89 -0
- frontend/scripts.js +443 -0
- frontend/styles.css +212 -0
- frontend/utils.js +83 -0
- lfss/cli/panel.py +45 -0
- lfss/cli/user.py +16 -3
- lfss/src/database.py +203 -48
- lfss/src/error.py +8 -0
- lfss/src/server.py +110 -29
- {lfss-0.1.0.dist-info → lfss-0.2.3.dist-info}/METADATA +13 -8
- lfss-0.2.3.dist-info/RECORD +24 -0
- {lfss-0.1.0.dist-info → lfss-0.2.3.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.3.dist-info}/WHEEL +0 -0
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.
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
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
|
-
|
307
|
-
|
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
|
-
|
312
|
-
if
|
313
|
-
|
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
|
396
|
+
UPDATE fmeta SET owner_id = ?, permission = ?,
|
317
397
|
access_time = CURRENT_TIMESTAMP WHERE url = ?
|
318
|
-
""", (
|
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.
|
323
|
-
|
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(
|
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)
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
406
|
-
|
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
|
-
|
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
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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, ""
|