lfss 0.7.15__py3-none-any.whl → 0.8.0__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 +2 -2
- docs/Permission.md +4 -2
- frontend/api.js +214 -8
- frontend/index.html +40 -28
- frontend/login.css +21 -0
- frontend/login.js +83 -0
- frontend/scripts.js +73 -84
- frontend/state.js +19 -4
- frontend/styles.css +26 -8
- frontend/thumb.css +6 -0
- frontend/thumb.js +6 -2
- lfss/{client → api}/__init__.py +52 -35
- lfss/{client/api.py → api/connector.py} +89 -8
- lfss/cli/cli.py +1 -1
- lfss/cli/user.py +1 -1
- lfss/src/connection_pool.py +3 -2
- lfss/src/database.py +158 -72
- lfss/src/datatype.py +8 -3
- lfss/src/error.py +3 -1
- lfss/src/server.py +67 -9
- lfss/src/stat.py +1 -1
- lfss/src/utils.py +47 -13
- {lfss-0.7.15.dist-info → lfss-0.8.0.dist-info}/METADATA +4 -3
- lfss-0.8.0.dist-info/RECORD +43 -0
- lfss-0.7.15.dist-info/RECORD +0 -41
- {lfss-0.7.15.dist-info → lfss-0.8.0.dist-info}/WHEEL +0 -0
- {lfss-0.7.15.dist-info → lfss-0.8.0.dist-info}/entry_points.txt +0 -0
@@ -1,9 +1,13 @@
|
|
1
|
-
from
|
1
|
+
from __future__ import annotations
|
2
|
+
from typing import Optional, Literal, Iterator
|
2
3
|
import os
|
3
4
|
import requests
|
5
|
+
import requests.adapters
|
4
6
|
import urllib.parse
|
7
|
+
from lfss.src.error import PathNotFoundError
|
5
8
|
from lfss.src.datatype import (
|
6
|
-
FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents
|
9
|
+
FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents,
|
10
|
+
FileSortKey, DirSortKey
|
7
11
|
)
|
8
12
|
from lfss.src.utils import ensure_uri_compnents
|
9
13
|
|
@@ -11,12 +15,41 @@ _default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
|
|
11
15
|
_default_token = os.environ.get('LFSS_TOKEN', '')
|
12
16
|
|
13
17
|
class Connector:
|
18
|
+
class Session:
|
19
|
+
def __init__(self, connector: Connector, pool_size: int = 10):
|
20
|
+
self.connector = connector
|
21
|
+
self.pool_size = pool_size
|
22
|
+
def open(self):
|
23
|
+
self.close()
|
24
|
+
if self.connector._session is None:
|
25
|
+
s = requests.Session()
|
26
|
+
adapter = requests.adapters.HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size)
|
27
|
+
s.mount('http://', adapter)
|
28
|
+
s.mount('https://', adapter)
|
29
|
+
self.connector._session = s
|
30
|
+
def close(self):
|
31
|
+
if self.connector._session is not None:
|
32
|
+
self.connector._session.close()
|
33
|
+
self.connector._session = None
|
34
|
+
def __call__(self):
|
35
|
+
return self.connector
|
36
|
+
def __enter__(self):
|
37
|
+
self.open()
|
38
|
+
return self.connector
|
39
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
40
|
+
self.close()
|
41
|
+
|
14
42
|
def __init__(self, endpoint=_default_endpoint, token=_default_token):
|
15
43
|
assert token, "No token provided. Please set LFSS_TOKEN environment variable."
|
16
44
|
self.config = {
|
17
45
|
"endpoint": endpoint,
|
18
46
|
"token": token
|
19
47
|
}
|
48
|
+
self._session: Optional[requests.Session] = None
|
49
|
+
|
50
|
+
def session(self, pool_size: int = 10):
|
51
|
+
""" avoid creating a new session for each request. """
|
52
|
+
return self.Session(self, pool_size)
|
20
53
|
|
21
54
|
def _fetch_factory(
|
22
55
|
self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
|
@@ -31,9 +64,13 @@ class Connector:
|
|
31
64
|
headers.update({
|
32
65
|
'Authorization': f"Bearer {self.config['token']}",
|
33
66
|
})
|
34
|
-
|
35
|
-
response =
|
67
|
+
if self._session is not None:
|
68
|
+
response = self._session.request(method, url, headers=headers, **kwargs)
|
36
69
|
response.raise_for_status()
|
70
|
+
else:
|
71
|
+
with requests.Session() as s:
|
72
|
+
response = s.request(method, url, headers=headers, **kwargs)
|
73
|
+
response.raise_for_status()
|
37
74
|
return response
|
38
75
|
return f
|
39
76
|
|
@@ -80,9 +117,9 @@ class Connector:
|
|
80
117
|
)
|
81
118
|
return response.json()
|
82
119
|
|
83
|
-
def _get(self, path: str) -> Optional[requests.Response]:
|
120
|
+
def _get(self, path: str, stream: bool = False) -> Optional[requests.Response]:
|
84
121
|
try:
|
85
|
-
response = self._fetch_factory('GET', path)()
|
122
|
+
response = self._fetch_factory('GET', path)(stream=stream)
|
86
123
|
except requests.exceptions.HTTPError as e:
|
87
124
|
if e.response.status_code == 404:
|
88
125
|
return None
|
@@ -94,6 +131,12 @@ class Connector:
|
|
94
131
|
response = self._get(path)
|
95
132
|
if response is None: return None
|
96
133
|
return response.content
|
134
|
+
|
135
|
+
def get_stream(self, path: str) -> Iterator[bytes]:
|
136
|
+
"""Downloads a file from the specified path, will raise PathNotFoundError if path not found."""
|
137
|
+
response = self._get(path, stream=True)
|
138
|
+
if response is None: raise PathNotFoundError("Path not found: " + path)
|
139
|
+
return response.iter_content(chunk_size=1024)
|
97
140
|
|
98
141
|
def get_json(self, path: str) -> Optional[dict]:
|
99
142
|
response = self._get(path)
|
@@ -118,12 +161,50 @@ class Connector:
|
|
118
161
|
return None
|
119
162
|
raise e
|
120
163
|
|
121
|
-
def list_path(self, path: str
|
164
|
+
def list_path(self, path: str) -> PathContents:
|
165
|
+
"""
|
166
|
+
shorthand list with limited options,
|
167
|
+
for large directories / more options, use list_files and list_dirs instead.
|
168
|
+
"""
|
122
169
|
assert path.endswith('/')
|
123
|
-
response = self._fetch_factory('GET', path
|
170
|
+
response = self._fetch_factory('GET', path)()
|
124
171
|
dirs = [DirectoryRecord(**d) for d in response.json()['dirs']]
|
125
172
|
files = [FileRecord(**f) for f in response.json()['files']]
|
126
173
|
return PathContents(dirs=dirs, files=files)
|
174
|
+
|
175
|
+
def count_files(self, path: str, flat: bool = False) -> int:
|
176
|
+
assert path.endswith('/')
|
177
|
+
response = self._fetch_factory('GET', '_api/count-files', {'path': path, 'flat': flat})()
|
178
|
+
return response.json()['count']
|
179
|
+
|
180
|
+
def list_files(
|
181
|
+
self, path: str, offset: int = 0, limit: int = 1000,
|
182
|
+
order_by: FileSortKey = '', order_desc: bool = False,
|
183
|
+
flat: bool = False
|
184
|
+
) -> list[FileRecord]:
|
185
|
+
assert path.endswith('/')
|
186
|
+
response = self._fetch_factory('GET', "_api/list-files", {
|
187
|
+
'path': path,
|
188
|
+
'offset': offset, 'limit': limit, 'order_by': order_by, 'order_desc': order_desc, 'flat': flat
|
189
|
+
})()
|
190
|
+
return [FileRecord(**f) for f in response.json()]
|
191
|
+
|
192
|
+
def count_dirs(self, path: str) -> int:
|
193
|
+
assert path.endswith('/')
|
194
|
+
response = self._fetch_factory('GET', '_api/count-dirs', {'path': path})()
|
195
|
+
return response.json()['count']
|
196
|
+
|
197
|
+
def list_dirs(
|
198
|
+
self, path: str, offset: int = 0, limit: int = 1000,
|
199
|
+
order_by: DirSortKey = '', order_desc: bool = False,
|
200
|
+
skim: bool = True
|
201
|
+
) -> list[DirectoryRecord]:
|
202
|
+
assert path.endswith('/')
|
203
|
+
response = self._fetch_factory('GET', "_api/list-dirs", {
|
204
|
+
'path': path,
|
205
|
+
'offset': offset, 'limit': limit, 'order_by': order_by, 'order_desc': order_desc, 'skim': skim
|
206
|
+
})()
|
207
|
+
return [DirectoryRecord(**d) for d in response.json()]
|
127
208
|
|
128
209
|
def set_file_permission(self, path: str, permission: int | FileReadPermission):
|
129
210
|
"""Sets the file permission for the specified path."""
|
lfss/cli/cli.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
from lfss.
|
1
|
+
from lfss.api import Connector, upload_directory, upload_file, download_file, download_directory
|
2
2
|
from pathlib import Path
|
3
3
|
import argparse
|
4
4
|
from lfss.src.datatype import FileReadPermission
|
lfss/cli/user.py
CHANGED
@@ -29,7 +29,7 @@ async def _main():
|
|
29
29
|
sp_set.add_argument('username', type=str)
|
30
30
|
sp_set.add_argument('-p', '--password', type=str, default=None)
|
31
31
|
sp_set.add_argument('-a', '--admin', type=parse_bool, default=None)
|
32
|
-
sp_set.add_argument('--permission', type=
|
32
|
+
sp_set.add_argument('--permission', type=parse_permission, default=None)
|
33
33
|
sp_set.add_argument('--max-storage', type=parse_storage_size, default=None)
|
34
34
|
|
35
35
|
sp_list = sp.add_parser('list')
|
lfss/src/connection_pool.py
CHANGED
@@ -46,7 +46,7 @@ class SqlConnection:
|
|
46
46
|
|
47
47
|
class SqlConnectionPool:
|
48
48
|
_r_sem: Semaphore
|
49
|
-
_w_sem: Semaphore
|
49
|
+
_w_sem: Lock | Semaphore
|
50
50
|
def __init__(self):
|
51
51
|
self._readers: list[SqlConnection] = []
|
52
52
|
self._writer: None | SqlConnection = None
|
@@ -57,7 +57,8 @@ class SqlConnectionPool:
|
|
57
57
|
self._readers = []
|
58
58
|
|
59
59
|
self._writer = SqlConnection(await get_connection(read_only=False))
|
60
|
-
self._w_sem =
|
60
|
+
self._w_sem = Lock()
|
61
|
+
# self._w_sem = Semaphore(1)
|
61
62
|
|
62
63
|
for _ in range(n_read):
|
63
64
|
conn = await get_connection(read_only=True)
|
lfss/src/database.py
CHANGED
@@ -10,10 +10,13 @@ import aiosqlite, aiofiles
|
|
10
10
|
import aiofiles.os
|
11
11
|
|
12
12
|
from .connection_pool import execute_sql, unique_cursor, transaction
|
13
|
-
from .datatype import
|
13
|
+
from .datatype import (
|
14
|
+
UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents,
|
15
|
+
FileSortKey, DirSortKey, isValidFileSortKey, isValidDirSortKey
|
16
|
+
)
|
14
17
|
from .config import LARGE_BLOB_DIR, CHUNK_SIZE
|
15
18
|
from .log import get_logger
|
16
|
-
from .utils import decode_uri_compnents, hash_credential, concurrent_wrap
|
19
|
+
from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async
|
17
20
|
from .error import *
|
18
21
|
|
19
22
|
class DBObjectBase(ABC):
|
@@ -156,55 +159,108 @@ class FileConn(DBObjectBase):
|
|
156
159
|
dirs = [await self.get_path_record(u) for u in dirnames] if not skim else [DirectoryRecord(u) for u in dirnames]
|
157
160
|
return dirs
|
158
161
|
|
159
|
-
async def
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
return PathContents([], files)
|
162
|
+
async def count_path_dirs(self, url: str):
|
163
|
+
if not url.endswith('/'): url += '/'
|
164
|
+
if url == '/': url = ''
|
165
|
+
cursor = await self.cur.execute("""
|
166
|
+
SELECT COUNT(*) FROM (
|
167
|
+
SELECT DISTINCT SUBSTR(
|
168
|
+
url, LENGTH(?) + 1,
|
169
|
+
INSTR(SUBSTR(url, LENGTH(?) + 1), '/')
|
170
|
+
) AS dirname
|
171
|
+
FROM fmeta WHERE url LIKE ? AND dirname != ''
|
172
|
+
)
|
173
|
+
""", (url, url, url + '%'))
|
174
|
+
res = await cursor.fetchone()
|
175
|
+
assert res is not None, "Error: count_path_dirs"
|
176
|
+
return res[0]
|
175
177
|
|
178
|
+
async def list_path_dirs(
|
179
|
+
self, url: str,
|
180
|
+
offset: int = 0, limit: int = int(1e5),
|
181
|
+
order_by: DirSortKey = '', order_desc: bool = False,
|
182
|
+
skim: bool = True
|
183
|
+
) -> list[DirectoryRecord]:
|
184
|
+
if not isValidDirSortKey(order_by):
|
185
|
+
raise ValueError(f"Invalid order_by ({order_by})")
|
186
|
+
|
187
|
+
if not url.endswith('/'): url += '/'
|
188
|
+
if url == '/': url = ''
|
189
|
+
|
190
|
+
sql_qury = """
|
191
|
+
SELECT DISTINCT SUBSTR(
|
192
|
+
url,
|
193
|
+
1 + LENGTH(?),
|
194
|
+
INSTR(SUBSTR(url, 1 + LENGTH(?)), '/')
|
195
|
+
) AS dirname
|
196
|
+
FROM fmeta WHERE url LIKE ? AND dirname != ''
|
197
|
+
""" \
|
198
|
+
+ (f"ORDER BY {order_by} {'DESC' if order_desc else 'ASC'}" if order_by else '') \
|
199
|
+
+ " LIMIT ? OFFSET ?"
|
200
|
+
cursor = await self.cur.execute(sql_qury, (url, url, url + '%', limit, offset))
|
201
|
+
res = await cursor.fetchall()
|
202
|
+
dirs_str = [r[0] for r in res]
|
203
|
+
async def get_dir(dir_url):
|
204
|
+
if skim:
|
205
|
+
return DirectoryRecord(dir_url)
|
176
206
|
else:
|
177
|
-
return
|
178
|
-
|
207
|
+
return await self.get_path_record(dir_url)
|
208
|
+
dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
|
209
|
+
return dirs
|
210
|
+
|
211
|
+
async def count_path_files(self, url: str, flat: bool = False):
|
212
|
+
if not url.endswith('/'): url += '/'
|
213
|
+
if url == '/': url = ''
|
179
214
|
if flat:
|
180
|
-
cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', ))
|
181
|
-
|
182
|
-
|
183
|
-
|
215
|
+
cursor = await self.cur.execute("SELECT COUNT(*) FROM fmeta WHERE url LIKE ?", (url + '%', ))
|
216
|
+
else:
|
217
|
+
cursor = await self.cur.execute("SELECT COUNT(*) FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%'))
|
218
|
+
res = await cursor.fetchone()
|
219
|
+
assert res is not None, "Error: count_path_files"
|
220
|
+
return res[0]
|
184
221
|
|
185
|
-
|
222
|
+
async def list_path_files(
|
223
|
+
self, url: str,
|
224
|
+
offset: int = 0, limit: int = int(1e5),
|
225
|
+
order_by: FileSortKey = '', order_desc: bool = False,
|
226
|
+
flat: bool = False,
|
227
|
+
) -> list[FileRecord]:
|
228
|
+
if not isValidFileSortKey(order_by):
|
229
|
+
raise ValueError(f"Invalid order_by {order_by}")
|
230
|
+
|
231
|
+
if not url.endswith('/'): url += '/'
|
232
|
+
if url == '/': url = ''
|
233
|
+
|
234
|
+
sql_query = "SELECT * FROM fmeta WHERE url LIKE ?"
|
235
|
+
if not flat: sql_query += " AND url NOT LIKE ?"
|
236
|
+
if order_by: sql_query += f" ORDER BY {order_by} {'DESC' if order_desc else 'ASC'}"
|
237
|
+
sql_query += " LIMIT ? OFFSET ?"
|
238
|
+
if flat:
|
239
|
+
cursor = await self.cur.execute(sql_query, (url + '%', limit, offset))
|
240
|
+
else:
|
241
|
+
cursor = await self.cur.execute(sql_query, (url + '%', url + '%/%', limit, offset))
|
186
242
|
res = await cursor.fetchall()
|
187
243
|
files = [self.parse_record(r) for r in res]
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
244
|
+
return files
|
245
|
+
|
246
|
+
async def list_path(self, url: str) -> PathContents:
|
247
|
+
"""
|
248
|
+
List all files and directories under the given path.
|
249
|
+
This method is a handy way file browsing, but has limitaions:
|
250
|
+
- It does not support pagination
|
251
|
+
- It does not support sorting
|
252
|
+
- It cannot flatten directories
|
253
|
+
- It cannot list directories with details
|
254
|
+
"""
|
255
|
+
MAX_ITEMS = int(1e4)
|
256
|
+
dir_count = await self.count_path_dirs(url)
|
257
|
+
file_count = await self.count_path_files(url, flat=False)
|
258
|
+
if dir_count + file_count > MAX_ITEMS:
|
259
|
+
raise TooManyItemsError("Too many items, please paginate")
|
260
|
+
return PathContents(
|
261
|
+
dirs = await self.list_path_dirs(url, skim=True, limit=MAX_ITEMS),
|
262
|
+
files = await self.list_path_files(url, flat=False, limit=MAX_ITEMS)
|
201
263
|
)
|
202
|
-
res = await cursor.fetchall()
|
203
|
-
dirs_str = [r[0] + '/' for r in res if r[0] != '/']
|
204
|
-
async def get_dir(dir_url):
|
205
|
-
return DirectoryRecord(dir_url, -1)
|
206
|
-
dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
|
207
|
-
return PathContents(dirs, files)
|
208
264
|
|
209
265
|
async def get_path_record(self, url: str) -> DirectoryRecord:
|
210
266
|
"""
|
@@ -361,7 +417,8 @@ class FileConn(DBObjectBase):
|
|
361
417
|
async def set_file_blob(self, file_id: str, blob: bytes):
|
362
418
|
await self.cur.execute("INSERT OR REPLACE INTO blobs.fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
|
363
419
|
|
364
|
-
|
420
|
+
@staticmethod
|
421
|
+
async def set_file_blob_external(file_id: str, stream: AsyncIterable[bytes])->int:
|
365
422
|
size_sum = 0
|
366
423
|
try:
|
367
424
|
async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'wb') as f:
|
@@ -389,7 +446,8 @@ class FileConn(DBObjectBase):
|
|
389
446
|
if not chunk: break
|
390
447
|
yield chunk
|
391
448
|
|
392
|
-
|
449
|
+
@staticmethod
|
450
|
+
async def delete_file_blob_external(file_id: str):
|
393
451
|
if (LARGE_BLOB_DIR / file_id).exists():
|
394
452
|
await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
|
395
453
|
|
@@ -399,6 +457,36 @@ class FileConn(DBObjectBase):
|
|
399
457
|
async def delete_file_blobs(self, file_ids: list[str]):
|
400
458
|
await self.cur.execute("DELETE FROM blobs.fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
|
401
459
|
|
460
|
+
_log_active_queue = []
|
461
|
+
_log_active_lock = asyncio.Lock()
|
462
|
+
@debounce_async()
|
463
|
+
async def _set_all_active():
|
464
|
+
async with transaction() as conn:
|
465
|
+
uconn = UserConn(conn)
|
466
|
+
async with _log_active_lock:
|
467
|
+
for u in _log_active_queue:
|
468
|
+
await uconn.set_active(u)
|
469
|
+
_log_active_queue.clear()
|
470
|
+
async def delayed_log_activity(username: str):
|
471
|
+
async with _log_active_lock:
|
472
|
+
_log_active_queue.append(username)
|
473
|
+
await _set_all_active()
|
474
|
+
|
475
|
+
_log_access_queue = []
|
476
|
+
_log_access_lock = asyncio.Lock()
|
477
|
+
@debounce_async()
|
478
|
+
async def _log_all_access():
|
479
|
+
async with transaction() as conn:
|
480
|
+
fconn = FileConn(conn)
|
481
|
+
async with _log_access_lock:
|
482
|
+
for r in _log_access_queue:
|
483
|
+
await fconn.log_access(r)
|
484
|
+
_log_access_queue.clear()
|
485
|
+
async def delayed_log_access(url: str):
|
486
|
+
async with _log_access_lock:
|
487
|
+
_log_access_queue.append(url)
|
488
|
+
await _log_all_access()
|
489
|
+
|
402
490
|
def validate_url(url: str, is_file = True):
|
403
491
|
prohibited_chars = ['..', ';', "'", '"', '\\', '\0', '\n', '\r', '\t', '\x0b', '\x0c']
|
404
492
|
ret = not url.startswith('/') and not url.startswith('_') and not url.startswith('.')
|
@@ -433,11 +521,6 @@ class Database:
|
|
433
521
|
await execute_sql(conn, 'init.sql')
|
434
522
|
return self
|
435
523
|
|
436
|
-
async def record_user_activity(self, u: str):
|
437
|
-
async with transaction() as conn:
|
438
|
-
uconn = UserConn(conn)
|
439
|
-
await uconn.set_active(u)
|
440
|
-
|
441
524
|
async def update_file_record(self, user: UserRecord, url: str, permission: FileReadPermission):
|
442
525
|
validate_url(url)
|
443
526
|
async with transaction() as conn:
|
@@ -459,9 +542,7 @@ class Database:
|
|
459
542
|
if file_size is not provided, the blob must be bytes
|
460
543
|
"""
|
461
544
|
validate_url(url)
|
462
|
-
async with
|
463
|
-
uconn = UserConn(cur)
|
464
|
-
fconn = FileConn(cur)
|
545
|
+
async with unique_cursor() as cur:
|
465
546
|
user = await get_user(cur, u)
|
466
547
|
if user is None:
|
467
548
|
return
|
@@ -477,27 +558,35 @@ class Database:
|
|
477
558
|
if await get_user(cur, first_component) is None:
|
478
559
|
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
479
560
|
|
480
|
-
|
561
|
+
fconn_r = FileConn(cur)
|
562
|
+
user_size_used = await fconn_r.user_size(user.id)
|
563
|
+
|
481
564
|
if isinstance(blob, bytes):
|
482
565
|
file_size = len(blob)
|
483
566
|
if user_size_used + file_size > user.max_storage:
|
484
567
|
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
485
568
|
f_id = uuid.uuid4().hex
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
569
|
+
|
570
|
+
async with transaction() as w_cur:
|
571
|
+
fconn_w = FileConn(w_cur)
|
572
|
+
await fconn_w.set_file_blob(f_id, blob)
|
573
|
+
await fconn_w.set_file_record(
|
574
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
575
|
+
permission=permission, external=False, mime_type=mime_type)
|
490
576
|
else:
|
491
577
|
assert isinstance(blob, AsyncIterable)
|
492
578
|
f_id = uuid.uuid4().hex
|
493
|
-
file_size = await
|
579
|
+
file_size = await FileConn.set_file_blob_external(f_id, blob)
|
494
580
|
if user_size_used + file_size > user.max_storage:
|
495
|
-
await
|
581
|
+
await FileConn.delete_file_blob_external(f_id)
|
496
582
|
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
583
|
+
|
584
|
+
async with transaction() as w_cur:
|
585
|
+
await FileConn(w_cur).set_file_record(
|
586
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
587
|
+
permission=permission, external=True, mime_type=mime_type)
|
588
|
+
|
589
|
+
await delayed_log_activity(user.username)
|
501
590
|
|
502
591
|
async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
|
503
592
|
validate_url(url)
|
@@ -510,9 +599,7 @@ class Database:
|
|
510
599
|
raise ValueError(f"File {url} is not stored externally, should use read_file instead")
|
511
600
|
ret = fconn.get_file_blob_external(r.file_id)
|
512
601
|
|
513
|
-
|
514
|
-
await FileConn(w_cur).log_access(url)
|
515
|
-
|
602
|
+
await delayed_log_access(url)
|
516
603
|
return ret
|
517
604
|
|
518
605
|
|
@@ -532,9 +619,7 @@ class Database:
|
|
532
619
|
if blob is None:
|
533
620
|
raise FileNotFoundError(f"File {url} data not found")
|
534
621
|
|
535
|
-
|
536
|
-
await FileConn(w_cur).log_access(url)
|
537
|
-
|
622
|
+
await delayed_log_access(url)
|
538
623
|
return blob
|
539
624
|
|
540
625
|
async def delete_file(self, url: str, assure_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
|
@@ -653,7 +738,8 @@ class Database:
|
|
653
738
|
async with unique_cursor() as cur:
|
654
739
|
fconn = FileConn(cur)
|
655
740
|
if urls is None:
|
656
|
-
|
741
|
+
fcount = await fconn.count_path_files(top_url, flat=True)
|
742
|
+
urls = [r.url for r in (await fconn.list_path_files(top_url, flat=True, limit=fcount))]
|
657
743
|
|
658
744
|
for url in urls:
|
659
745
|
if not url.startswith(top_url):
|
lfss/src/datatype.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
from enum import IntEnum
|
2
|
+
from typing import Literal
|
2
3
|
import dataclasses
|
3
4
|
|
4
5
|
class FileReadPermission(IntEnum):
|
@@ -51,6 +52,10 @@ class DirectoryRecord:
|
|
51
52
|
|
52
53
|
@dataclasses.dataclass
|
53
54
|
class PathContents:
|
54
|
-
dirs: list[DirectoryRecord]
|
55
|
-
files: list[FileRecord]
|
56
|
-
|
55
|
+
dirs: list[DirectoryRecord] = dataclasses.field(default_factory=list)
|
56
|
+
files: list[FileRecord] = dataclasses.field(default_factory=list)
|
57
|
+
|
58
|
+
FileSortKey = Literal['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
|
59
|
+
isValidFileSortKey = lambda x: x in ['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
|
60
|
+
DirSortKey = Literal['', 'dirname']
|
61
|
+
isValidDirSortKey = lambda x: x in ['', 'dirname']
|
lfss/src/error.py
CHANGED
@@ -7,4 +7,6 @@ class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
|
|
7
7
|
|
8
8
|
class InvalidPathError(LFSSExceptionBase, ValueError):...
|
9
9
|
|
10
|
-
class StorageExceededError(LFSSExceptionBase):...
|
10
|
+
class StorageExceededError(LFSSExceptionBase):...
|
11
|
+
|
12
|
+
class TooManyItemsError(LFSSExceptionBase):...
|