lfss 0.6.0__py3-none-any.whl → 0.7.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.
- frontend/scripts.js +1 -1
- lfss/cli/balance.py +14 -11
- lfss/cli/cli.py +1 -1
- lfss/cli/user.py +34 -32
- lfss/client/api.py +22 -2
- lfss/sql/init.sql +5 -5
- lfss/sql/pragma.sql +7 -4
- lfss/src/config.py +1 -0
- lfss/src/connection_pool.py +151 -0
- lfss/src/database.py +253 -327
- lfss/src/datatype.py +1 -1
- lfss/src/server.py +69 -75
- {lfss-0.6.0.dist-info → lfss-0.7.1.dist-info}/METADATA +1 -1
- {lfss-0.6.0.dist-info → lfss-0.7.1.dist-info}/RECORD +16 -15
- {lfss-0.6.0.dist-info → lfss-0.7.1.dist-info}/WHEEL +0 -0
- {lfss-0.6.0.dist-info → lfss-0.7.1.dist-info}/entry_points.txt +0 -0
lfss/src/database.py
CHANGED
@@ -1,102 +1,76 @@
|
|
1
1
|
|
2
2
|
from typing import Optional, overload, Literal, AsyncIterable
|
3
|
-
from abc import ABC
|
3
|
+
from abc import ABC
|
4
4
|
|
5
5
|
import urllib.parse
|
6
|
-
from pathlib import Path
|
7
6
|
import hashlib, uuid
|
8
|
-
from contextlib import asynccontextmanager
|
9
|
-
from functools import wraps
|
10
7
|
import zipfile, io, asyncio
|
11
8
|
|
12
9
|
import aiosqlite, aiofiles
|
13
10
|
import aiofiles.os
|
14
|
-
from asyncio import Lock
|
15
11
|
|
12
|
+
from .connection_pool import execute_sql, unique_cursor, transaction
|
16
13
|
from .datatype import UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents
|
17
|
-
from .config import
|
14
|
+
from .config import LARGE_BLOB_DIR
|
18
15
|
from .log import get_logger
|
19
16
|
from .utils import decode_uri_compnents
|
20
17
|
from .error import *
|
21
18
|
|
22
|
-
_g_conn: Optional[aiosqlite.Connection] = None
|
23
|
-
|
24
19
|
def hash_credential(username, password):
|
25
20
|
return hashlib.sha256((username + password).encode()).hexdigest()
|
26
21
|
|
27
|
-
|
28
|
-
this_dir = Path(__file__).parent
|
29
|
-
sql_dir = this_dir.parent / 'sql'
|
30
|
-
async with aiofiles.open(sql_dir / name, 'r') as f:
|
31
|
-
sql = await f.read()
|
32
|
-
sql = sql.split(';')
|
33
|
-
for s in sql:
|
34
|
-
await conn.execute(s)
|
35
|
-
|
36
|
-
_atomic_lock = Lock()
|
37
|
-
def atomic(func):
|
38
|
-
""" Ensure non-reentrancy """
|
39
|
-
@wraps(func)
|
40
|
-
async def wrapper(*args, **kwargs):
|
41
|
-
async with _atomic_lock:
|
42
|
-
return await func(*args, **kwargs)
|
43
|
-
return wrapper
|
44
|
-
|
45
|
-
class DBConnBase(ABC):
|
22
|
+
class DBObjectBase(ABC):
|
46
23
|
logger = get_logger('database', global_instance=True)
|
24
|
+
_cur: aiosqlite.Cursor
|
47
25
|
|
48
|
-
|
49
|
-
|
50
|
-
global _g_conn
|
51
|
-
if _g_conn is None:
|
52
|
-
raise ValueError('Connection not initialized, did you forget to call super().init()?')
|
53
|
-
return _g_conn
|
26
|
+
def set_cursor(self, cur: aiosqlite.Cursor):
|
27
|
+
self._cur = cur
|
54
28
|
|
55
|
-
@
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
_g_conn = await aiosqlite.connect(DATA_HOME / 'lfss.db')
|
61
|
-
await execute_sql(_g_conn, 'pragma.sql')
|
62
|
-
await execute_sql(_g_conn, 'init.sql')
|
29
|
+
@property
|
30
|
+
def cur(self)->aiosqlite.Cursor:
|
31
|
+
if not hasattr(self, '_cur'):
|
32
|
+
raise ValueError("Connection not set")
|
33
|
+
return self._cur
|
63
34
|
|
64
|
-
async def commit(self):
|
65
|
-
|
35
|
+
# async def commit(self):
|
36
|
+
# await self.conn.commit()
|
66
37
|
|
67
38
|
DECOY_USER = UserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
|
68
|
-
class UserConn(
|
39
|
+
class UserConn(DBObjectBase):
|
40
|
+
|
41
|
+
def __init__(self, cur: aiosqlite.Cursor) -> None:
|
42
|
+
super().__init__()
|
43
|
+
self.set_cursor(cur)
|
69
44
|
|
70
45
|
@staticmethod
|
71
46
|
def parse_record(record) -> UserRecord:
|
72
47
|
return UserRecord(*record)
|
73
48
|
|
74
|
-
async def init(self):
|
75
|
-
|
49
|
+
async def init(self, cur: aiosqlite.Cursor):
|
50
|
+
self.set_cursor(cur)
|
76
51
|
return self
|
77
52
|
|
78
53
|
async def get_user(self, username: str) -> Optional[UserRecord]:
|
79
|
-
|
80
|
-
|
54
|
+
await self.cur.execute("SELECT * FROM user WHERE username = ?", (username, ))
|
55
|
+
res = await self.cur.fetchone()
|
81
56
|
|
82
57
|
if res is None: return None
|
83
58
|
return self.parse_record(res)
|
84
59
|
|
85
60
|
async def get_user_by_id(self, user_id: int) -> Optional[UserRecord]:
|
86
|
-
|
87
|
-
|
61
|
+
await self.cur.execute("SELECT * FROM user WHERE id = ?", (user_id, ))
|
62
|
+
res = await self.cur.fetchone()
|
88
63
|
|
89
64
|
if res is None: return None
|
90
65
|
return self.parse_record(res)
|
91
66
|
|
92
67
|
async def get_user_by_credential(self, credential: str) -> Optional[UserRecord]:
|
93
|
-
|
94
|
-
|
68
|
+
await self.cur.execute("SELECT * FROM user WHERE credential = ?", (credential, ))
|
69
|
+
res = await self.cur.fetchone()
|
95
70
|
|
96
71
|
if res is None: return None
|
97
72
|
return self.parse_record(res)
|
98
73
|
|
99
|
-
@atomic
|
100
74
|
async def create_user(
|
101
75
|
self, username: str, password: str, is_admin: bool = False,
|
102
76
|
max_storage: int = 1073741824, permission: FileReadPermission = FileReadPermission.UNSET
|
@@ -107,12 +81,11 @@ class UserConn(DBConnBase):
|
|
107
81
|
self.logger.debug(f"Creating user {username}")
|
108
82
|
credential = hash_credential(username, password)
|
109
83
|
assert await self.get_user(username) is None, "Duplicate username"
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
84
|
+
await self.cur.execute("INSERT INTO user (username, credential, is_admin, max_storage, permission) VALUES (?, ?, ?, ?, ?)", (username, credential, is_admin, max_storage, permission))
|
85
|
+
self.logger.info(f"User {username} created")
|
86
|
+
assert self.cur.lastrowid is not None
|
87
|
+
return self.cur.lastrowid
|
114
88
|
|
115
|
-
@atomic
|
116
89
|
async def update_user(
|
117
90
|
self, username: str, password: Optional[str] = None, is_admin: Optional[bool] = None,
|
118
91
|
max_storage: Optional[int] = None, permission: Optional[FileReadPermission] = None
|
@@ -134,112 +107,60 @@ class UserConn(DBConnBase):
|
|
134
107
|
if max_storage is None: max_storage = current_record.max_storage
|
135
108
|
if permission is None: permission = current_record.permission
|
136
109
|
|
137
|
-
await self.
|
110
|
+
await self.cur.execute(
|
138
111
|
"UPDATE user SET credential = ?, is_admin = ?, max_storage = ?, permission = ? WHERE username = ?",
|
139
112
|
(credential, is_admin, max_storage, int(permission), username)
|
140
113
|
)
|
141
114
|
self.logger.info(f"User {username} updated")
|
142
115
|
|
143
116
|
async def all(self):
|
144
|
-
|
145
|
-
|
146
|
-
|
117
|
+
await self.cur.execute("SELECT * FROM user")
|
118
|
+
for record in await self.cur.fetchall():
|
119
|
+
yield self.parse_record(record)
|
147
120
|
|
148
|
-
@atomic
|
149
121
|
async def set_active(self, username: str):
|
150
|
-
await self.
|
122
|
+
await self.cur.execute("UPDATE user SET last_active = CURRENT_TIMESTAMP WHERE username = ?", (username, ))
|
151
123
|
|
152
|
-
@atomic
|
153
124
|
async def delete_user(self, username: str):
|
154
|
-
await self.
|
125
|
+
await self.cur.execute("DELETE FROM user WHERE username = ?", (username, ))
|
155
126
|
self.logger.info(f"Delete user {username}")
|
156
127
|
|
157
|
-
class FileConn(
|
128
|
+
class FileConn(DBObjectBase):
|
129
|
+
|
130
|
+
def __init__(self, cur: aiosqlite.Cursor) -> None:
|
131
|
+
super().__init__()
|
132
|
+
self.set_cursor(cur)
|
158
133
|
|
159
134
|
@staticmethod
|
160
135
|
def parse_record(record) -> FileRecord:
|
161
136
|
return FileRecord(*record)
|
162
137
|
|
163
|
-
|
164
|
-
|
165
|
-
# backward compatibility, since 0.2.1
|
166
|
-
async with self.conn.execute("SELECT * FROM user") as cursor:
|
167
|
-
res = await cursor.fetchall()
|
168
|
-
for r in res:
|
169
|
-
async with self.conn.execute("SELECT user_id FROM usize WHERE user_id = ?", (r[0], )) as cursor:
|
170
|
-
size = await cursor.fetchone()
|
171
|
-
if size is None:
|
172
|
-
async with self.conn.execute("SELECT SUM(file_size) FROM fmeta WHERE owner_id = ?", (r[0], )) as cursor:
|
173
|
-
size = await cursor.fetchone()
|
174
|
-
if size is not None and size[0] is not None:
|
175
|
-
await self._user_size_inc(r[0], size[0])
|
176
|
-
|
177
|
-
# backward compatibility, since 0.5.0
|
178
|
-
# 'external' means the file is not stored in the database, but in the external storage
|
179
|
-
async with self.conn.execute("SELECT * FROM fmeta") as cursor:
|
180
|
-
res = await cursor.fetchone()
|
181
|
-
if res and len(res) < 8:
|
182
|
-
self.logger.info("Updating fmeta table")
|
183
|
-
await self.conn.execute('''
|
184
|
-
ALTER TABLE fmeta ADD COLUMN external BOOLEAN DEFAULT FALSE
|
185
|
-
''')
|
186
|
-
|
187
|
-
# backward compatibility, since 0.6.0
|
188
|
-
async with self.conn.execute("SELECT * FROM fmeta") as cursor:
|
189
|
-
res = await cursor.fetchone()
|
190
|
-
if res and len(res) < 9:
|
191
|
-
self.logger.info("Updating fmeta table")
|
192
|
-
await self.conn.execute('''
|
193
|
-
ALTER TABLE fmeta ADD COLUMN mime_type TEXT DEFAULT 'application/octet-stream'
|
194
|
-
''')
|
195
|
-
# check all mime types
|
196
|
-
import mimetypes, mimesniff
|
197
|
-
async with self.conn.execute("SELECT url, file_id, external FROM fmeta") as cursor:
|
198
|
-
res = await cursor.fetchall()
|
199
|
-
async with self.conn.execute("SELECT count(*) FROM fmeta") as cursor:
|
200
|
-
count = await cursor.fetchone()
|
201
|
-
assert count is not None
|
202
|
-
for counter, r in enumerate(res, start=1):
|
203
|
-
print(f"Checking mimetype for {counter}/{count[0]}")
|
204
|
-
url, f_id, external = r
|
205
|
-
fname = url.split('/')[-1]
|
206
|
-
mime_type, _ = mimetypes.guess_type(fname)
|
207
|
-
if mime_type is None:
|
208
|
-
# try to sniff the file
|
209
|
-
if not external:
|
210
|
-
async with self.conn.execute("SELECT data FROM fdata WHERE file_id = ?", (f_id, )) as cursor:
|
211
|
-
blob = await cursor.fetchone()
|
212
|
-
assert blob is not None
|
213
|
-
blob = blob[0]
|
214
|
-
mime_type = mimesniff.what(blob)
|
215
|
-
else:
|
216
|
-
mime_type = mimesniff.what(LARGE_BLOB_DIR / f_id)
|
217
|
-
await self.conn.execute("UPDATE fmeta SET mime_type = ? WHERE url = ?", (mime_type, url))
|
218
|
-
|
138
|
+
def init(self, cur: aiosqlite.Cursor):
|
139
|
+
self.set_cursor(cur)
|
219
140
|
return self
|
220
141
|
|
221
142
|
async def get_file_record(self, url: str) -> Optional[FileRecord]:
|
222
|
-
|
223
|
-
|
143
|
+
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url = ?", (url, ))
|
144
|
+
res = await cursor.fetchone()
|
224
145
|
if res is None:
|
225
146
|
return None
|
226
147
|
return self.parse_record(res)
|
227
148
|
|
228
149
|
async def get_file_records(self, urls: list[str]) -> list[FileRecord]:
|
229
|
-
|
230
|
-
|
150
|
+
await self.cur.execute("SELECT * FROM fmeta WHERE url IN ({})".format(','.join(['?'] * len(urls))), urls)
|
151
|
+
res = await self.cur.fetchall()
|
231
152
|
if res is None:
|
232
153
|
return []
|
233
154
|
return [self.parse_record(r) for r in res]
|
234
155
|
|
235
156
|
async def get_user_file_records(self, owner_id: int) -> list[FileRecord]:
|
236
|
-
|
237
|
-
|
157
|
+
await self.cur.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, ))
|
158
|
+
res = await self.cur.fetchall()
|
238
159
|
return [self.parse_record(r) for r in res]
|
239
160
|
|
240
161
|
async def get_path_file_records(self, url: str) -> list[FileRecord]:
|
241
|
-
|
242
|
-
|
162
|
+
await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', ))
|
163
|
+
res = await self.cur.fetchall()
|
243
164
|
return [self.parse_record(r) for r in res]
|
244
165
|
|
245
166
|
async def list_root(self, *usernames: str) -> list[DirectoryRecord]:
|
@@ -248,8 +169,8 @@ class FileConn(DBConnBase):
|
|
248
169
|
"""
|
249
170
|
if not usernames:
|
250
171
|
# list all users
|
251
|
-
|
252
|
-
|
172
|
+
await self.cur.execute("SELECT username FROM user")
|
173
|
+
res = await self.cur.fetchall()
|
253
174
|
dirnames = [u[0] + '/' for u in res]
|
254
175
|
dirs = [DirectoryRecord(u, await self.path_size(u, include_subpath=True)) for u in dirnames]
|
255
176
|
return dirs
|
@@ -276,24 +197,24 @@ class FileConn(DBConnBase):
|
|
276
197
|
# users cannot be queried using '/', because we store them without '/' prefix,
|
277
198
|
# so we need to handle this case separately,
|
278
199
|
if flat:
|
279
|
-
|
280
|
-
|
200
|
+
cursor = await self.cur.execute("SELECT * FROM fmeta")
|
201
|
+
res = await cursor.fetchall()
|
281
202
|
return [self.parse_record(r) for r in res]
|
282
203
|
|
283
204
|
else:
|
284
205
|
return PathContents(await self.list_root(), [])
|
285
206
|
|
286
207
|
if flat:
|
287
|
-
|
288
|
-
|
208
|
+
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', ))
|
209
|
+
res = await cursor.fetchall()
|
289
210
|
return [self.parse_record(r) for r in res]
|
290
211
|
|
291
|
-
|
292
|
-
|
212
|
+
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%'))
|
213
|
+
res = await cursor.fetchall()
|
293
214
|
files = [self.parse_record(r) for r in res]
|
294
215
|
|
295
216
|
# substr indexing starts from 1
|
296
|
-
|
217
|
+
cursor = await self.cur.execute(
|
297
218
|
"""
|
298
219
|
SELECT DISTINCT
|
299
220
|
SUBSTR(
|
@@ -304,8 +225,8 @@ class FileConn(DBConnBase):
|
|
304
225
|
FROM fmeta WHERE url LIKE ?
|
305
226
|
""",
|
306
227
|
(url, url, url + '%')
|
307
|
-
)
|
308
|
-
|
228
|
+
)
|
229
|
+
res = await cursor.fetchall()
|
309
230
|
dirs_str = [r[0] + '/' for r in res if r[0] != '/']
|
310
231
|
async def get_dir(dir_url):
|
311
232
|
return DirectoryRecord(dir_url, -1)
|
@@ -314,46 +235,45 @@ class FileConn(DBConnBase):
|
|
314
235
|
|
315
236
|
async def get_path_record(self, url: str) -> DirectoryRecord:
|
316
237
|
assert url.endswith('/'), "Path must end with /"
|
317
|
-
|
238
|
+
cursor = await self.cur.execute("""
|
318
239
|
SELECT MIN(create_time) as create_time,
|
319
240
|
MAX(create_time) as update_time,
|
320
241
|
MAX(access_time) as access_time
|
321
242
|
FROM fmeta
|
322
243
|
WHERE url LIKE ?
|
323
|
-
""", (url + '%', ))
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
244
|
+
""", (url + '%', ))
|
245
|
+
result = await cursor.fetchone()
|
246
|
+
if result is None or any(val is None for val in result):
|
247
|
+
raise PathNotFoundError(f"Path {url} not found")
|
248
|
+
create_time, update_time, access_time = result
|
328
249
|
p_size = await self.path_size(url, include_subpath=True)
|
329
250
|
return DirectoryRecord(url, p_size, create_time=create_time, update_time=update_time, access_time=access_time)
|
330
251
|
|
331
252
|
async def user_size(self, user_id: int) -> int:
|
332
|
-
|
333
|
-
|
253
|
+
cursor = await self.cur.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, ))
|
254
|
+
res = await cursor.fetchone()
|
334
255
|
if res is None:
|
335
256
|
return -1
|
336
257
|
return res[0]
|
337
258
|
async def _user_size_inc(self, user_id: int, inc: int):
|
338
259
|
self.logger.debug(f"Increasing user {user_id} size by {inc}")
|
339
|
-
await self.
|
260
|
+
await self.cur.execute("INSERT OR REPLACE INTO usize (user_id, size) VALUES (?, COALESCE((SELECT size FROM usize WHERE user_id = ?), 0) + ?)", (user_id, user_id, inc))
|
340
261
|
async def _user_size_dec(self, user_id: int, dec: int):
|
341
262
|
self.logger.debug(f"Decreasing user {user_id} size by {dec}")
|
342
|
-
await self.
|
263
|
+
await self.cur.execute("INSERT OR REPLACE INTO usize (user_id, size) VALUES (?, COALESCE((SELECT size FROM usize WHERE user_id = ?), 0) - ?)", (user_id, user_id, dec))
|
343
264
|
|
344
265
|
async def path_size(self, url: str, include_subpath = False) -> int:
|
345
266
|
if not url.endswith('/'):
|
346
267
|
url += '/'
|
347
268
|
if not include_subpath:
|
348
|
-
|
349
|
-
|
269
|
+
cursor = await self.cur.execute("SELECT SUM(file_size) FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%'))
|
270
|
+
res = await cursor.fetchone()
|
350
271
|
else:
|
351
|
-
|
352
|
-
|
272
|
+
cursor = await self.cur.execute("SELECT SUM(file_size) FROM fmeta WHERE url LIKE ?", (url + '%', ))
|
273
|
+
res = await cursor.fetchone()
|
353
274
|
assert res is not None
|
354
275
|
return res[0] or 0
|
355
276
|
|
356
|
-
@atomic
|
357
277
|
async def update_file_record(
|
358
278
|
self, url, owner_id: Optional[int] = None, permission: Optional[FileReadPermission] = None
|
359
279
|
):
|
@@ -363,13 +283,12 @@ class FileConn(DBConnBase):
|
|
363
283
|
owner_id = old.owner_id
|
364
284
|
if permission is None:
|
365
285
|
permission = old.permission
|
366
|
-
await self.
|
286
|
+
await self.cur.execute(
|
367
287
|
"UPDATE fmeta SET owner_id = ?, permission = ? WHERE url = ?",
|
368
288
|
(owner_id, int(permission), url)
|
369
289
|
)
|
370
290
|
self.logger.info(f"Updated file {url}")
|
371
291
|
|
372
|
-
@atomic
|
373
292
|
async def set_file_record(
|
374
293
|
self, url: str,
|
375
294
|
owner_id: int,
|
@@ -383,14 +302,13 @@ class FileConn(DBConnBase):
|
|
383
302
|
if permission is None:
|
384
303
|
permission = FileReadPermission.UNSET
|
385
304
|
assert owner_id is not None and file_id is not None and file_size is not None and external is not None
|
386
|
-
await self.
|
305
|
+
await self.cur.execute(
|
387
306
|
"INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external, mime_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
388
307
|
(url, owner_id, file_id, file_size, int(permission), external, mime_type)
|
389
308
|
)
|
390
309
|
await self._user_size_inc(owner_id, file_size)
|
391
310
|
self.logger.info(f"File {url} created")
|
392
311
|
|
393
|
-
@atomic
|
394
312
|
async def move_file(self, old_url: str, new_url: str):
|
395
313
|
old = await self.get_file_record(old_url)
|
396
314
|
if old is None:
|
@@ -398,70 +316,64 @@ class FileConn(DBConnBase):
|
|
398
316
|
new_exists = await self.get_file_record(new_url)
|
399
317
|
if new_exists is not None:
|
400
318
|
raise FileExistsError(f"File {new_url} already exists")
|
401
|
-
|
402
|
-
|
319
|
+
await self.cur.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url))
|
320
|
+
self.logger.info(f"Moved file {old_url} to {new_url}")
|
403
321
|
|
404
|
-
@atomic
|
405
322
|
async def move_path(self, old_url: str, new_url: str, conflict_handler: Literal['skip', 'overwrite'] = 'overwrite', user_id: Optional[int] = None):
|
406
323
|
assert old_url.endswith('/'), "Old path must end with /"
|
407
324
|
assert new_url.endswith('/'), "New path must end with /"
|
408
325
|
if user_id is None:
|
409
|
-
|
410
|
-
|
326
|
+
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (old_url + '%', ))
|
327
|
+
res = await cursor.fetchall()
|
411
328
|
else:
|
412
|
-
|
413
|
-
|
329
|
+
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ? AND owner_id = ?", (old_url + '%', user_id))
|
330
|
+
res = await cursor.fetchall()
|
414
331
|
for r in res:
|
415
332
|
new_r = new_url + r[0][len(old_url):]
|
416
333
|
if conflict_handler == 'overwrite':
|
417
|
-
await self.
|
334
|
+
await self.cur.execute("DELETE FROM fmeta WHERE url = ?", (new_r, ))
|
418
335
|
elif conflict_handler == 'skip':
|
419
|
-
if (await self.
|
336
|
+
if (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))) is not None:
|
420
337
|
continue
|
421
|
-
await self.
|
338
|
+
await self.cur.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_r, r[0]))
|
422
339
|
|
423
340
|
async def log_access(self, url: str):
|
424
|
-
await self.
|
341
|
+
await self.cur.execute("UPDATE fmeta SET access_time = CURRENT_TIMESTAMP WHERE url = ?", (url, ))
|
425
342
|
|
426
|
-
@atomic
|
427
343
|
async def delete_file_record(self, url: str):
|
428
344
|
file_record = await self.get_file_record(url)
|
429
345
|
if file_record is None: return
|
430
|
-
await self.
|
346
|
+
await self.cur.execute("DELETE FROM fmeta WHERE url = ?", (url, ))
|
431
347
|
await self._user_size_dec(file_record.owner_id, file_record.file_size)
|
432
348
|
self.logger.info(f"Deleted fmeta {url}")
|
433
349
|
|
434
|
-
@atomic
|
435
350
|
async def delete_user_file_records(self, owner_id: int):
|
436
|
-
|
437
|
-
|
438
|
-
await self.
|
439
|
-
await self.
|
351
|
+
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, ))
|
352
|
+
res = await cursor.fetchall()
|
353
|
+
await self.cur.execute("DELETE FROM fmeta WHERE owner_id = ?", (owner_id, ))
|
354
|
+
await self.cur.execute("DELETE FROM usize WHERE user_id = ?", (owner_id, ))
|
440
355
|
self.logger.info(f"Deleted {len(res)} files for user {owner_id}") # type: ignore
|
441
356
|
|
442
|
-
@atomic
|
443
357
|
async def delete_path_records(self, path: str):
|
444
358
|
"""Delete all records with url starting with path"""
|
445
|
-
|
446
|
-
|
359
|
+
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (path + '%', ))
|
360
|
+
all_f_rec = await cursor.fetchall()
|
447
361
|
|
448
362
|
# update user size
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
363
|
+
cursor = await self.cur.execute("SELECT DISTINCT owner_id FROM fmeta WHERE url LIKE ?", (path + '%', ))
|
364
|
+
res = await cursor.fetchall()
|
365
|
+
for r in res:
|
366
|
+
cursor = await self.cur.execute("SELECT SUM(file_size) FROM fmeta WHERE owner_id = ? AND url LIKE ?", (r[0], path + '%'))
|
367
|
+
size = await cursor.fetchone()
|
368
|
+
if size is not None:
|
369
|
+
await self._user_size_dec(r[0], size[0])
|
456
370
|
|
457
|
-
await self.
|
371
|
+
await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ?", (path + '%', ))
|
458
372
|
self.logger.info(f"Deleted {len(all_f_rec)} files for path {path}") # type: ignore
|
459
373
|
|
460
|
-
@atomic
|
461
374
|
async def set_file_blob(self, file_id: str, blob: bytes):
|
462
|
-
await self.
|
375
|
+
await self.cur.execute("INSERT OR REPLACE INTO blobs.fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
|
463
376
|
|
464
|
-
@atomic
|
465
377
|
async def set_file_blob_external(self, file_id: str, stream: AsyncIterable[bytes])->int:
|
466
378
|
size_sum = 0
|
467
379
|
try:
|
@@ -476,8 +388,8 @@ class FileConn(DBConnBase):
|
|
476
388
|
return size_sum
|
477
389
|
|
478
390
|
async def get_file_blob(self, file_id: str) -> Optional[bytes]:
|
479
|
-
|
480
|
-
|
391
|
+
cursor = await self.cur.execute("SELECT data FROM blobs.fdata WHERE file_id = ?", (file_id, ))
|
392
|
+
res = await cursor.fetchone()
|
481
393
|
if res is None:
|
482
394
|
return None
|
483
395
|
return res[0]
|
@@ -488,18 +400,15 @@ class FileConn(DBConnBase):
|
|
488
400
|
async for chunk in f:
|
489
401
|
yield chunk
|
490
402
|
|
491
|
-
@atomic
|
492
403
|
async def delete_file_blob_external(self, file_id: str):
|
493
404
|
if (LARGE_BLOB_DIR / file_id).exists():
|
494
405
|
await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
|
495
406
|
|
496
|
-
@atomic
|
497
407
|
async def delete_file_blob(self, file_id: str):
|
498
|
-
await self.
|
408
|
+
await self.cur.execute("DELETE FROM blobs.fdata WHERE file_id = ?", (file_id, ))
|
499
409
|
|
500
|
-
@atomic
|
501
410
|
async def delete_file_blobs(self, file_ids: list[str]):
|
502
|
-
await self.
|
411
|
+
await self.cur.execute("DELETE FROM blobs.fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
|
503
412
|
|
504
413
|
def validate_url(url: str, is_file = True):
|
505
414
|
prohibited_chars = ['..', ';', "'", '"', '\\', '\0', '\n', '\r', '\t', '\x0b', '\x0c']
|
@@ -517,53 +426,40 @@ def validate_url(url: str, is_file = True):
|
|
517
426
|
if not ret:
|
518
427
|
raise InvalidPathError(f"Invalid URL: {url}")
|
519
428
|
|
520
|
-
async def get_user(
|
429
|
+
async def get_user(cur: aiosqlite.Cursor, user: int | str) -> Optional[UserRecord]:
|
430
|
+
uconn = UserConn(cur)
|
521
431
|
if isinstance(user, str):
|
522
|
-
return await
|
432
|
+
return await uconn.get_user(user)
|
523
433
|
elif isinstance(user, int):
|
524
|
-
return await
|
434
|
+
return await uconn.get_user_by_id(user)
|
525
435
|
else:
|
526
436
|
return None
|
527
437
|
|
528
|
-
|
529
|
-
@asynccontextmanager
|
530
|
-
async def transaction(db: "Database"):
|
531
|
-
try:
|
532
|
-
await _transaction_lock.acquire()
|
533
|
-
yield
|
534
|
-
await db.commit()
|
535
|
-
except Exception as e:
|
536
|
-
db.logger.error(f"Error in transaction: {e}")
|
537
|
-
await db.rollback()
|
538
|
-
raise e
|
539
|
-
finally:
|
540
|
-
_transaction_lock.release()
|
541
|
-
|
438
|
+
# mostly transactional operations
|
542
439
|
class Database:
|
543
|
-
user: UserConn = UserConn()
|
544
|
-
file: FileConn = FileConn()
|
545
440
|
logger = get_logger('database', global_instance=True)
|
546
441
|
|
547
442
|
async def init(self):
|
548
|
-
async with transaction(
|
549
|
-
await
|
550
|
-
await self.file.init()
|
443
|
+
async with transaction() as conn:
|
444
|
+
await execute_sql(conn, 'init.sql')
|
551
445
|
return self
|
552
446
|
|
553
|
-
async def
|
554
|
-
|
555
|
-
|
556
|
-
await
|
447
|
+
async def record_user_activity(self, u: str):
|
448
|
+
async with transaction() as conn:
|
449
|
+
uconn = UserConn(conn)
|
450
|
+
await uconn.set_active(u)
|
557
451
|
|
558
|
-
async def
|
559
|
-
|
560
|
-
|
452
|
+
async def update_file_record(self, user: UserRecord, url: str, permission: FileReadPermission):
|
453
|
+
validate_url(url)
|
454
|
+
async with transaction() as conn:
|
455
|
+
fconn = FileConn(conn)
|
456
|
+
r = await fconn.get_file_record(url)
|
457
|
+
if r is None:
|
458
|
+
raise PathNotFoundError(f"File {url} not found")
|
459
|
+
if r.owner_id != user.id and not user.is_admin:
|
460
|
+
raise PermissionDeniedError(f"Permission denied: {user.username} cannot update file {url}")
|
461
|
+
await fconn.update_file_record(url, permission=permission)
|
561
462
|
|
562
|
-
async def rollback(self):
|
563
|
-
global _g_conn
|
564
|
-
if _g_conn is not None:
|
565
|
-
await _g_conn.rollback()
|
566
|
-
|
567
463
|
async def save_file(
|
568
464
|
self, u: int | str, url: str,
|
569
465
|
blob: bytes | AsyncIterable[bytes],
|
@@ -574,105 +470,130 @@ class Database:
|
|
574
470
|
if file_size is not provided, the blob must be bytes
|
575
471
|
"""
|
576
472
|
validate_url(url)
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
|
596
|
-
if
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
await
|
473
|
+
async with transaction() as cur:
|
474
|
+
uconn = UserConn(cur)
|
475
|
+
fconn = FileConn(cur)
|
476
|
+
user = await get_user(cur, u)
|
477
|
+
if user is None:
|
478
|
+
return
|
479
|
+
|
480
|
+
# check if the user is the owner of the path, or is admin
|
481
|
+
if url.startswith('/'):
|
482
|
+
url = url[1:]
|
483
|
+
first_component = url.split('/')[0]
|
484
|
+
if first_component != user.username:
|
485
|
+
if not user.is_admin:
|
486
|
+
raise PermissionDeniedError(f"Permission denied: {user.username} cannot write to {url}")
|
487
|
+
else:
|
488
|
+
if await get_user(cur, first_component) is None:
|
489
|
+
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
490
|
+
|
491
|
+
user_size_used = await fconn.user_size(user.id)
|
492
|
+
if isinstance(blob, bytes):
|
493
|
+
file_size = len(blob)
|
494
|
+
if user_size_used + file_size > user.max_storage:
|
495
|
+
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
496
|
+
f_id = uuid.uuid4().hex
|
497
|
+
await fconn.set_file_blob(f_id, blob)
|
498
|
+
await fconn.set_file_record(
|
602
499
|
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
603
500
|
permission=permission, external=False, mime_type=mime_type)
|
604
|
-
|
605
|
-
|
606
|
-
assert isinstance(blob, AsyncIterable)
|
607
|
-
async with transaction(self):
|
501
|
+
else:
|
502
|
+
assert isinstance(blob, AsyncIterable)
|
608
503
|
f_id = uuid.uuid4().hex
|
609
|
-
file_size = await
|
504
|
+
file_size = await fconn.set_file_blob_external(f_id, blob)
|
610
505
|
if user_size_used + file_size > user.max_storage:
|
611
|
-
await
|
506
|
+
await fconn.delete_file_blob_external(f_id)
|
612
507
|
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
613
|
-
await
|
508
|
+
await fconn.set_file_record(
|
614
509
|
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
615
510
|
permission=permission, external=True, mime_type=mime_type)
|
616
|
-
|
511
|
+
await uconn.set_active(user.username)
|
617
512
|
|
618
513
|
async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
|
619
514
|
validate_url(url)
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
515
|
+
async with unique_cursor() as cur:
|
516
|
+
fconn = FileConn(cur)
|
517
|
+
r = await fconn.get_file_record(url)
|
518
|
+
if r is None:
|
519
|
+
raise FileNotFoundError(f"File {url} not found")
|
520
|
+
if not r.external:
|
521
|
+
raise ValueError(f"File {url} is not stored externally, should use read_file instead")
|
522
|
+
return fconn.get_file_blob_external(r.file_id)
|
626
523
|
|
627
524
|
async def read_file(self, url: str) -> bytes:
|
628
525
|
validate_url(url)
|
629
526
|
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
527
|
+
async with transaction() as cur:
|
528
|
+
fconn = FileConn(cur)
|
529
|
+
r = await fconn.get_file_record(url)
|
530
|
+
if r is None:
|
531
|
+
raise FileNotFoundError(f"File {url} not found")
|
532
|
+
if r.external:
|
533
|
+
raise ValueError(f"File {url} is stored externally, should use read_file_stream instead")
|
635
534
|
|
636
|
-
|
637
|
-
|
638
|
-
|
639
|
-
|
640
|
-
|
641
|
-
async with transaction(self):
|
642
|
-
await self.file.log_access(url)
|
535
|
+
f_id = r.file_id
|
536
|
+
blob = await fconn.get_file_blob(f_id)
|
537
|
+
if blob is None:
|
538
|
+
raise FileNotFoundError(f"File {url} data not found")
|
539
|
+
await fconn.log_access(url)
|
643
540
|
|
644
541
|
return blob
|
645
542
|
|
646
543
|
async def delete_file(self, url: str) -> Optional[FileRecord]:
|
647
544
|
validate_url(url)
|
648
545
|
|
649
|
-
async with transaction(
|
650
|
-
|
546
|
+
async with transaction() as cur:
|
547
|
+
fconn = FileConn(cur)
|
548
|
+
r = await fconn.get_file_record(url)
|
651
549
|
if r is None:
|
652
550
|
return None
|
653
551
|
f_id = r.file_id
|
654
|
-
await
|
552
|
+
await fconn.delete_file_record(url)
|
655
553
|
if r.external:
|
656
|
-
await
|
554
|
+
await fconn.delete_file_blob_external(f_id)
|
657
555
|
else:
|
658
|
-
await
|
556
|
+
await fconn.delete_file_blob(f_id)
|
659
557
|
return r
|
660
558
|
|
661
559
|
async def move_file(self, old_url: str, new_url: str):
|
662
560
|
validate_url(old_url)
|
663
561
|
validate_url(new_url)
|
664
562
|
|
665
|
-
async with transaction(
|
666
|
-
|
563
|
+
async with transaction() as cur:
|
564
|
+
fconn = FileConn(cur)
|
565
|
+
await fconn.move_file(old_url, new_url)
|
667
566
|
|
668
|
-
async def move_path(self,
|
567
|
+
async def move_path(self, user: UserRecord, old_url: str, new_url: str):
|
669
568
|
validate_url(old_url, is_file=False)
|
670
569
|
validate_url(new_url, is_file=False)
|
671
570
|
|
672
|
-
|
673
|
-
|
571
|
+
if new_url.startswith('/'):
|
572
|
+
new_url = new_url[1:]
|
573
|
+
if old_url.startswith('/'):
|
574
|
+
old_url = old_url[1:]
|
575
|
+
assert old_url != new_url, "Old and new path must be different"
|
576
|
+
assert old_url.endswith('/'), "Old path must end with /"
|
577
|
+
assert new_url.endswith('/'), "New path must end with /"
|
578
|
+
|
579
|
+
async with transaction() as cur:
|
580
|
+
first_component = new_url.split('/')[0]
|
581
|
+
if not (first_component == user.username or user.is_admin):
|
582
|
+
raise PermissionDeniedError(f"Permission denied: path must start with {user.username}")
|
583
|
+
elif user.is_admin:
|
584
|
+
uconn = UserConn(cur)
|
585
|
+
_is_user = await uconn.get_user(first_component)
|
586
|
+
if not _is_user:
|
587
|
+
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
588
|
+
|
589
|
+
# check if old path is under user's directory (non-admin)
|
590
|
+
if not old_url.startswith(user.username + '/') and not user.is_admin:
|
591
|
+
raise PermissionDeniedError(f"Permission denied: {user.username} cannot move path {old_url}")
|
674
592
|
|
675
|
-
|
593
|
+
fconn = FileConn(cur)
|
594
|
+
await fconn.move_path(old_url, new_url, 'overwrite', user.id)
|
595
|
+
|
596
|
+
async def __batch_delete_file_blobs(self, fconn: FileConn, file_records: list[FileRecord], batch_size: int = 512):
|
676
597
|
# https://github.com/langchain-ai/langchain/issues/10321
|
677
598
|
internal_ids = []
|
678
599
|
external_ids = []
|
@@ -683,52 +604,57 @@ class Database:
|
|
683
604
|
internal_ids.append(r.file_id)
|
684
605
|
|
685
606
|
for i in range(0, len(internal_ids), batch_size):
|
686
|
-
await
|
607
|
+
await fconn.delete_file_blobs([r for r in internal_ids[i:i+batch_size]])
|
687
608
|
for i in range(0, len(external_ids)):
|
688
|
-
await
|
609
|
+
await fconn.delete_file_blob_external(external_ids[i])
|
689
610
|
|
690
611
|
|
691
612
|
async def delete_path(self, url: str):
|
692
613
|
validate_url(url, is_file=False)
|
693
614
|
|
694
|
-
async with transaction(
|
695
|
-
|
615
|
+
async with transaction() as cur:
|
616
|
+
fconn = FileConn(cur)
|
617
|
+
records = await fconn.get_path_file_records(url)
|
696
618
|
if not records:
|
697
619
|
return None
|
698
|
-
await self.__batch_delete_file_blobs(records)
|
699
|
-
await
|
620
|
+
await self.__batch_delete_file_blobs(fconn, records)
|
621
|
+
await fconn.delete_path_records(url)
|
700
622
|
return records
|
701
623
|
|
702
624
|
async def delete_user(self, u: str | int):
|
703
|
-
|
704
|
-
|
705
|
-
|
706
|
-
|
707
|
-
|
708
|
-
|
709
|
-
await
|
710
|
-
await self.
|
711
|
-
await
|
625
|
+
async with transaction() as cur:
|
626
|
+
user = await get_user(cur, u)
|
627
|
+
if user is None:
|
628
|
+
return
|
629
|
+
|
630
|
+
fconn = FileConn(cur)
|
631
|
+
records = await fconn.get_user_file_records(user.id)
|
632
|
+
await self.__batch_delete_file_blobs(fconn, records)
|
633
|
+
await fconn.delete_user_file_records(user.id)
|
634
|
+
uconn = UserConn(cur)
|
635
|
+
await uconn.delete_user(user.username)
|
712
636
|
|
713
637
|
async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes | AsyncIterable[bytes]]]:
|
714
|
-
|
715
|
-
|
638
|
+
async with unique_cursor() as cur:
|
639
|
+
fconn = FileConn(cur)
|
640
|
+
if urls is None:
|
641
|
+
urls = [r.url for r in await fconn.list_path(top_url, flat=True)]
|
716
642
|
|
717
|
-
|
718
|
-
|
719
|
-
|
720
|
-
|
721
|
-
|
722
|
-
continue
|
723
|
-
f_id = r.file_id
|
724
|
-
if r.external:
|
725
|
-
blob = self.file.get_file_blob_external(f_id)
|
726
|
-
else:
|
727
|
-
blob = await self.file.get_file_blob(f_id)
|
728
|
-
if blob is None:
|
729
|
-
self.logger.warning(f"Blob not found for {url}")
|
643
|
+
for url in urls:
|
644
|
+
if not url.startswith(top_url):
|
645
|
+
continue
|
646
|
+
r = await fconn.get_file_record(url)
|
647
|
+
if r is None:
|
730
648
|
continue
|
731
|
-
|
649
|
+
f_id = r.file_id
|
650
|
+
if r.external:
|
651
|
+
blob = fconn.get_file_blob_external(f_id)
|
652
|
+
else:
|
653
|
+
blob = await fconn.get_file_blob(f_id)
|
654
|
+
if blob is None:
|
655
|
+
self.logger.warning(f"Blob not found for {url}")
|
656
|
+
continue
|
657
|
+
yield r, blob
|
732
658
|
|
733
659
|
async def zip_path(self, top_url: str, urls: Optional[list[str]]) -> io.BytesIO:
|
734
660
|
if top_url.startswith('/'):
|