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.
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, abstractmethod
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 DATA_HOME, LARGE_BLOB_DIR
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
- async def execute_sql(conn: aiosqlite.Connection, name: str):
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
- @property
49
- def conn(self)->aiosqlite.Connection:
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
- @abstractmethod
56
- async def init(self):
57
- """Should return self"""
58
- global _g_conn
59
- if _g_conn is None:
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
- await self.conn.commit()
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(DBConnBase):
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
- await super().init()
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
- async with self.conn.execute("SELECT * FROM user WHERE username = ?", (username, )) as cursor:
80
- res = await cursor.fetchone()
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
- async with self.conn.execute("SELECT * FROM user WHERE id = ?", (user_id, )) as cursor:
87
- res = await cursor.fetchone()
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
- async with self.conn.execute("SELECT * FROM user WHERE credential = ?", (credential, )) as cursor:
94
- res = await cursor.fetchone()
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
- 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:
111
- self.logger.info(f"User {username} created")
112
- assert cursor.lastrowid is not None
113
- return cursor.lastrowid
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.conn.execute(
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
- async with self.conn.execute("SELECT * FROM user") as cursor:
145
- async for record in cursor:
146
- yield self.parse_record(record)
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.conn.execute("UPDATE user SET last_active = CURRENT_TIMESTAMP WHERE username = ?", (username, ))
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.conn.execute("DELETE FROM user WHERE username = ?", (username, ))
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(DBConnBase):
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
- async def init(self):
164
- await super().init()
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
- async with self.conn.execute("SELECT * FROM fmeta WHERE url = ?", (url, )) as cursor:
223
- res = await cursor.fetchone()
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
- async with self.conn.execute("SELECT * FROM fmeta WHERE url IN ({})".format(','.join(['?'] * len(urls))), urls) as cursor:
230
- res = await cursor.fetchall()
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
- async with self.conn.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, )) as cursor:
237
- res = await cursor.fetchall()
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
- async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
242
- res = await cursor.fetchall()
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
- async with self.conn.execute("SELECT username FROM user") as cursor:
252
- res = await cursor.fetchall()
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
- async with self.conn.execute("SELECT * FROM fmeta") as cursor:
280
- res = await cursor.fetchall()
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
- async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
288
- res = await cursor.fetchall()
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
- async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%')) as cursor:
292
- res = await cursor.fetchall()
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
- async with self.conn.execute(
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
- ) as cursor:
308
- res = await cursor.fetchall()
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
- async with self.conn.execute("""
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 + '%', )) as cursor:
324
- result = await cursor.fetchone()
325
- if result is None or any(val is None for val in result):
326
- raise PathNotFoundError(f"Path {url} not found")
327
- create_time, update_time, access_time = result
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
- async with self.conn.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, )) as cursor:
333
- res = await cursor.fetchone()
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.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))
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.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))
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
- async with self.conn.execute("SELECT SUM(file_size) FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%')) as cursor:
349
- res = await cursor.fetchone()
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
- async with self.conn.execute("SELECT SUM(file_size) FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
352
- res = await cursor.fetchone()
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.conn.execute(
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.conn.execute(
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
- async with self.conn.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url)):
402
- self.logger.info(f"Moved file {old_url} to {new_url}")
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
- async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (old_url + '%', )) as cursor:
410
- res = await cursor.fetchall()
326
+ cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (old_url + '%', ))
327
+ res = await cursor.fetchall()
411
328
  else:
412
- async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ? AND owner_id = ?", (old_url + '%', user_id)) as cursor:
413
- res = await cursor.fetchall()
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.conn.execute("DELETE FROM fmeta WHERE url = ?", (new_r, ))
334
+ await self.cur.execute("DELETE FROM fmeta WHERE url = ?", (new_r, ))
418
335
  elif conflict_handler == 'skip':
419
- if (await self.conn.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))) is not None:
336
+ if (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))) is not None:
420
337
  continue
421
- await self.conn.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_r, r[0]))
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.conn.execute("UPDATE fmeta SET access_time = CURRENT_TIMESTAMP WHERE url = ?", (url, ))
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.conn.execute("DELETE FROM fmeta WHERE url = ?", (url, ))
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
- async with self.conn.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, )) as cursor:
437
- res = await cursor.fetchall()
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, ))
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
- async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (path + '%', )) as cursor:
446
- all_f_rec = await cursor.fetchall()
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
- async with self.conn.execute("SELECT DISTINCT owner_id FROM fmeta WHERE url LIKE ?", (path + '%', )) as cursor:
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])
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.conn.execute("DELETE FROM fmeta WHERE url LIKE ?", (path + '%', ))
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.conn.execute("INSERT OR REPLACE INTO fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
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
- async with self.conn.execute("SELECT data FROM fdata WHERE file_id = ?", (file_id, )) as cursor:
480
- res = await cursor.fetchone()
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.conn.execute("DELETE FROM fdata WHERE file_id = ?", (file_id, ))
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.conn.execute("DELETE FROM fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
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(db: "Database", user: int | str) -> Optional[UserRecord]:
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 db.user.get_user(user)
432
+ return await uconn.get_user(user)
523
433
  elif isinstance(user, int):
524
- return await db.user.get_user_by_id(user)
434
+ return await uconn.get_user_by_id(user)
525
435
  else:
526
436
  return None
527
437
 
528
- _transaction_lock = Lock()
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(self):
549
- await self.user.init()
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 commit(self):
554
- global _g_conn
555
- if _g_conn is not None:
556
- await _g_conn.commit()
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 close(self):
559
- global _g_conn
560
- if _g_conn: await _g_conn.close()
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
- user = await get_user(self, u)
579
- if user is None:
580
- return
581
-
582
- # check if the user is the owner of the path, or is admin
583
- if url.startswith('/'):
584
- url = url[1:]
585
- first_component = url.split('/')[0]
586
- if first_component != user.username:
587
- if not user.is_admin:
588
- raise PermissionDeniedError(f"Permission denied: {user.username} cannot write to {url}")
589
- else:
590
- if await get_user(self, first_component) is None:
591
- raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
592
-
593
- user_size_used = await self.file.user_size(user.id)
594
- if isinstance(blob, bytes):
595
- file_size = len(blob)
596
- if user_size_used + file_size > user.max_storage:
597
- raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
598
- f_id = uuid.uuid4().hex
599
- async with transaction(self):
600
- await self.file.set_file_blob(f_id, blob)
601
- await self.file.set_file_record(
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
- await self.user.set_active(user.username)
605
- else:
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 self.file.set_file_blob_external(f_id, blob)
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 self.file.delete_file_blob_external(f_id)
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 self.file.set_file_record(
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
- await self.user.set_active(user.username)
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
- r = await self.file.get_file_record(url)
621
- if r is None:
622
- raise FileNotFoundError(f"File {url} not found")
623
- if not r.external:
624
- raise ValueError(f"File {url} is not stored externally, should use read_file instead")
625
- return self.file.get_file_blob_external(r.file_id)
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
- r = await self.file.get_file_record(url)
631
- if r is None:
632
- raise FileNotFoundError(f"File {url} not found")
633
- if r.external:
634
- raise ValueError(f"File {url} is stored externally, should use read_file_stream instead")
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
- f_id = r.file_id
637
- blob = await self.file.get_file_blob(f_id)
638
- if blob is None:
639
- raise FileNotFoundError(f"File {url} data not found")
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(self):
650
- r = await self.file.get_file_record(url)
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 self.file.delete_file_record(url)
552
+ await fconn.delete_file_record(url)
655
553
  if r.external:
656
- await self.file.delete_file_blob_external(f_id)
554
+ await fconn.delete_file_blob_external(f_id)
657
555
  else:
658
- await self.file.delete_file_blob(f_id)
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(self):
666
- await self.file.move_file(old_url, new_url)
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, old_url: str, new_url: str, user_id: Optional[int] = None):
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
- async with transaction(self):
673
- await self.file.move_path(old_url, new_url, 'overwrite', user_id)
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
- async def __batch_delete_file_blobs(self, file_records: list[FileRecord], batch_size: int = 512):
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 self.file.delete_file_blobs([r for r in internal_ids[i:i+batch_size]])
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 self.file.delete_file_blob_external(external_ids[i])
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(self):
695
- records = await self.file.get_path_file_records(url)
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 self.file.delete_path_records(url)
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
- user = await get_user(self, u)
704
- if user is None:
705
- return
706
-
707
- async with transaction(self):
708
- records = await self.file.get_user_file_records(user.id)
709
- await self.__batch_delete_file_blobs(records)
710
- await self.file.delete_user_file_records(user.id)
711
- await self.user.delete_user(user.username)
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
- if urls is None:
715
- urls = [r.url for r in await self.file.list_path(top_url, flat=True)]
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
- for url in urls:
718
- if not url.startswith(top_url):
719
- continue
720
- r = await self.file.get_file_record(url)
721
- if r is None:
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
- yield r, blob
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('/'):