lfss 0.4.1__tar.gz → 0.5.0__tar.gz
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-0.4.1 → lfss-0.5.0}/PKG-INFO +2 -1
- {lfss-0.4.1 → lfss-0.5.0}/frontend/api.js +7 -5
- {lfss-0.4.1 → lfss-0.5.0}/frontend/scripts.js +28 -10
- {lfss-0.4.1 → lfss-0.5.0}/frontend/styles.css +1 -0
- {lfss-0.4.1 → lfss-0.5.0}/frontend/utils.js +6 -0
- {lfss-0.4.1 → lfss-0.5.0}/lfss/cli/cli.py +5 -1
- {lfss-0.4.1 → lfss-0.5.0}/lfss/cli/user.py +2 -0
- {lfss-0.4.1 → lfss-0.5.0}/lfss/client/__init__.py +2 -0
- {lfss-0.4.1 → lfss-0.5.0}/lfss/client/api.py +9 -6
- {lfss-0.4.1 → lfss-0.5.0}/lfss/src/config.py +5 -2
- {lfss-0.4.1 → lfss-0.5.0}/lfss/src/database.py +155 -37
- {lfss-0.4.1 → lfss-0.5.0}/lfss/src/error.py +2 -0
- {lfss-0.4.1 → lfss-0.5.0}/lfss/src/server.py +46 -28
- {lfss-0.4.1 → lfss-0.5.0}/lfss/src/stat.py +1 -1
- {lfss-0.4.1 → lfss-0.5.0}/pyproject.toml +2 -1
- {lfss-0.4.1 → lfss-0.5.0}/Readme.md +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/docs/Known_issues.md +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/docs/Permission.md +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/frontend/index.html +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/frontend/popup.css +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/frontend/popup.js +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/lfss/cli/panel.py +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/lfss/cli/serve.py +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/lfss/src/__init__.py +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/lfss/src/log.py +0 -0
- {lfss-0.4.1 → lfss-0.5.0}/lfss/src/utils.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.5.0
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li, mengxun
|
@@ -11,6 +11,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.10
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
14
|
+
Requires-Dist: aiofiles (==23.*)
|
14
15
|
Requires-Dist: aiosqlite (==0.*)
|
15
16
|
Requires-Dist: fastapi (==0.*)
|
16
17
|
Requires-Dist: mimesniff (==1.*)
|
@@ -25,6 +25,8 @@
|
|
25
25
|
* @typedef {Object} DirectoryRecord
|
26
26
|
* @property {string} url - the url of the directory
|
27
27
|
* @property {string} size - the size of the directory, in bytes
|
28
|
+
* @property {string} create_time - the time the directory was created
|
29
|
+
* @property {string} access_time - the time the directory was last accessed
|
28
30
|
*
|
29
31
|
* @typedef {Object} PathListResponse
|
30
32
|
* @property {DirectoryRecord[]} dirs - the list of directories in the directory
|
@@ -112,12 +114,12 @@ export default class Connector {
|
|
112
114
|
}
|
113
115
|
|
114
116
|
/**
|
115
|
-
* @param {string} path - the path to the file
|
116
|
-
* @returns {Promise<FileRecord | null>} - the promise of the request
|
117
|
+
* @param {string} path - the path to the file or directory
|
118
|
+
* @returns {Promise<FileRecord | DirectoryRecord | null>} - the promise of the request
|
117
119
|
*/
|
118
120
|
async getMetadata(path){
|
119
121
|
if (path.startsWith('/')){ path = path.slice(1); }
|
120
|
-
const res = await fetch(this.config.endpoint + '/_api/
|
122
|
+
const res = await fetch(this.config.endpoint + '/_api/meta?path=' + path, {
|
121
123
|
method: 'GET',
|
122
124
|
headers: {
|
123
125
|
'Authorization': 'Bearer ' + this.config.token
|
@@ -171,7 +173,7 @@ export default class Connector {
|
|
171
173
|
*/
|
172
174
|
async setFilePermission(path, permission){
|
173
175
|
if (path.startsWith('/')){ path = path.slice(1); }
|
174
|
-
const dst = new URL(this.config.endpoint + '/_api/
|
176
|
+
const dst = new URL(this.config.endpoint + '/_api/meta');
|
175
177
|
dst.searchParams.append('path', path);
|
176
178
|
dst.searchParams.append('perm', permission);
|
177
179
|
const res = await fetch(dst.toString(), {
|
@@ -192,7 +194,7 @@ export default class Connector {
|
|
192
194
|
async moveFile(path, newPath){
|
193
195
|
if (path.startsWith('/')){ path = path.slice(1); }
|
194
196
|
if (newPath.startsWith('/')){ newPath = newPath.slice(1); }
|
195
|
-
const dst = new URL(this.config.endpoint + '/_api/
|
197
|
+
const dst = new URL(this.config.endpoint + '/_api/meta');
|
196
198
|
dst.searchParams.append('path', path);
|
197
199
|
dst.searchParams.append('new_path', newPath);
|
198
200
|
const res = await fetch(dst.toString(), {
|
@@ -79,6 +79,10 @@ pathBackButton.addEventListener('click', () => {
|
|
79
79
|
|
80
80
|
function onFileNameInpuChange(){
|
81
81
|
const fileName = uploadFileNameInput.value;
|
82
|
+
if (fileName.endsWith('/')){
|
83
|
+
uploadFileNameInput.classList.add('duplicate');
|
84
|
+
return;
|
85
|
+
}
|
82
86
|
if (fileName.length === 0){
|
83
87
|
uploadFileNameInput.classList.remove('duplicate');
|
84
88
|
}
|
@@ -219,6 +223,9 @@ function refreshFileList(){
|
|
219
223
|
|
220
224
|
data.dirs.forEach(dir => {
|
221
225
|
const tr = document.createElement('tr');
|
226
|
+
const sizeTd = document.createElement('td');
|
227
|
+
const accessTimeTd = document.createElement('td');
|
228
|
+
const createTimeTd = document.createElement('td');
|
222
229
|
{
|
223
230
|
const nameTd = document.createElement('td');
|
224
231
|
if (dir.url.endsWith('/')){
|
@@ -239,19 +246,14 @@ function refreshFileList(){
|
|
239
246
|
tr.appendChild(nameTd);
|
240
247
|
tbody.appendChild(tr);
|
241
248
|
}
|
242
|
-
|
243
249
|
{
|
244
|
-
|
250
|
+
// these are initialized meta
|
245
251
|
sizeTd.textContent = formatSize(dir.size);
|
246
252
|
tr.appendChild(sizeTd);
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
tr.appendChild(
|
251
|
-
}
|
252
|
-
{
|
253
|
-
const dateTd = document.createElement('td');
|
254
|
-
tr.appendChild(dateTd);
|
253
|
+
accessTimeTd.textContent = cvtGMT2Local(dir.access_time);
|
254
|
+
tr.appendChild(accessTimeTd);
|
255
|
+
createTimeTd.textContent = cvtGMT2Local(dir.create_time);
|
256
|
+
tr.appendChild(createTimeTd);
|
255
257
|
}
|
256
258
|
{
|
257
259
|
const accessTd = document.createElement('td');
|
@@ -262,6 +264,22 @@ function refreshFileList(){
|
|
262
264
|
const actContainer = document.createElement('div');
|
263
265
|
actContainer.classList.add('action-container');
|
264
266
|
|
267
|
+
const showMetaButton = document.createElement('a');
|
268
|
+
showMetaButton.textContent = 'Details';
|
269
|
+
showMetaButton.style.cursor = 'pointer';
|
270
|
+
showMetaButton.addEventListener('click', () => {
|
271
|
+
const dirUrlEncap = dir.url + (dir.url.endsWith('/') ? '' : '/');
|
272
|
+
conn.getMetadata(dirUrlEncap).then(
|
273
|
+
(meta) => {
|
274
|
+
sizeTd.textContent = formatSize(meta.size);
|
275
|
+
accessTimeTd.textContent = cvtGMT2Local(meta.access_time);
|
276
|
+
createTimeTd.textContent = cvtGMT2Local(meta.create_time);
|
277
|
+
}
|
278
|
+
);
|
279
|
+
showPopup('Fetching metadata...', {level: 'info', timeout: 3000});
|
280
|
+
});
|
281
|
+
actContainer.appendChild(showMetaButton);
|
282
|
+
|
265
283
|
const downloadButton = document.createElement('a');
|
266
284
|
downloadButton.textContent = 'Download';
|
267
285
|
downloadButton.href = conn.config.endpoint + '/_api/bundle?' +
|
@@ -1,5 +1,8 @@
|
|
1
1
|
|
2
2
|
export function formatSize(size){
|
3
|
+
if (size < 0){
|
4
|
+
return '';
|
5
|
+
}
|
3
6
|
const sizeInKb = size / 1024;
|
4
7
|
const sizeInMb = sizeInKb / 1024;
|
5
8
|
const sizeInGb = sizeInMb / 1024;
|
@@ -68,6 +71,9 @@ export function getRandomString(n, additionalCharset='0123456789_-(=)[]{}'){
|
|
68
71
|
* @returns {string}
|
69
72
|
*/
|
70
73
|
export function cvtGMT2Local(dateStr){
|
74
|
+
if (!dateStr || dateStr === 'N/A'){
|
75
|
+
return '';
|
76
|
+
}
|
71
77
|
const gmtdate = new Date(dateStr);
|
72
78
|
const localdate = new Date(gmtdate.getTime() + gmtdate.getTimezoneOffset() * 60000);
|
73
79
|
return localdate.toISOString().slice(0, 19).replace('T', ' ');
|
@@ -26,7 +26,7 @@ def main():
|
|
26
26
|
if args.command == "upload":
|
27
27
|
src_path = Path(args.src)
|
28
28
|
if src_path.is_dir():
|
29
|
-
upload_directory(
|
29
|
+
failed_upload = upload_directory(
|
30
30
|
connector, args.src, args.dst,
|
31
31
|
verbose=True,
|
32
32
|
n_concurrent=args.jobs,
|
@@ -35,6 +35,10 @@ def main():
|
|
35
35
|
overwrite=args.overwrite,
|
36
36
|
permission=args.permission
|
37
37
|
)
|
38
|
+
if failed_upload:
|
39
|
+
print("Failed to upload:")
|
40
|
+
for path in failed_upload:
|
41
|
+
print(f" {path}")
|
38
42
|
else:
|
39
43
|
with open(args.src, 'rb') as f:
|
40
44
|
connector.put(
|
@@ -39,6 +39,8 @@ def upload_directory(
|
|
39
39
|
connector.put(dst_path, blob, **put_kwargs)
|
40
40
|
break
|
41
41
|
except Exception as e:
|
42
|
+
if isinstance(e, KeyboardInterrupt):
|
43
|
+
raise e
|
42
44
|
if verbose:
|
43
45
|
print(f"[{this_count}] Error uploading {file_path}: {e}, retrying...")
|
44
46
|
this_try += 1
|
@@ -3,7 +3,7 @@ import os
|
|
3
3
|
import requests
|
4
4
|
import urllib.parse
|
5
5
|
from lfss.src.database import (
|
6
|
-
FileReadPermission, FileRecord, UserRecord, PathContents
|
6
|
+
FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents
|
7
7
|
)
|
8
8
|
|
9
9
|
_default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
|
@@ -63,11 +63,14 @@ class Connector:
|
|
63
63
|
path = path[1:]
|
64
64
|
self._fetch('DELETE', path)()
|
65
65
|
|
66
|
-
def get_metadata(self, path: str) -> Optional[FileRecord]:
|
66
|
+
def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
|
67
67
|
"""Gets the metadata for the file at the specified path."""
|
68
68
|
try:
|
69
|
-
response = self._fetch('GET', '_api/
|
70
|
-
|
69
|
+
response = self._fetch('GET', '_api/meta', {'path': path})()
|
70
|
+
if path.endswith('/'):
|
71
|
+
return DirectoryRecord(**response.json())
|
72
|
+
else:
|
73
|
+
return FileRecord(**response.json())
|
71
74
|
except requests.exceptions.HTTPError as e:
|
72
75
|
if e.response.status_code == 404:
|
73
76
|
return None
|
@@ -80,13 +83,13 @@ class Connector:
|
|
80
83
|
|
81
84
|
def set_file_permission(self, path: str, permission: int | FileReadPermission):
|
82
85
|
"""Sets the file permission for the specified path."""
|
83
|
-
self._fetch('POST', '_api/
|
86
|
+
self._fetch('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
|
84
87
|
headers={'Content-Type': 'application/www-form-urlencoded'}
|
85
88
|
)
|
86
89
|
|
87
90
|
def move_file(self, path: str, new_path: str):
|
88
91
|
"""Moves a file to a new location."""
|
89
|
-
self._fetch('POST', '_api/
|
92
|
+
self._fetch('POST', '_api/meta', {'path': path, 'new_path': new_path})(
|
90
93
|
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
91
94
|
)
|
92
95
|
|
@@ -7,6 +7,9 @@ DATA_HOME = Path(os.environ.get('LFSS_DATA', __default_dir))
|
|
7
7
|
if not DATA_HOME.exists():
|
8
8
|
DATA_HOME.mkdir()
|
9
9
|
print(f"[init] Created data home at {DATA_HOME}")
|
10
|
+
LARGE_BLOB_DIR = DATA_HOME / 'large_blobs'
|
11
|
+
LARGE_BLOB_DIR.mkdir(exist_ok=True)
|
10
12
|
|
11
|
-
|
12
|
-
|
13
|
+
LARGE_FILE_BYTES = 64 * 1024 * 1024 # 64MB
|
14
|
+
MAX_FILE_BYTES = 1024 * 1024 * 1024 # 1GB
|
15
|
+
MAX_BUNDLE_BYTES = 1024 * 1024 * 1024 # 1GB
|
@@ -7,12 +7,13 @@ import dataclasses, hashlib, uuid
|
|
7
7
|
from contextlib import asynccontextmanager
|
8
8
|
from functools import wraps
|
9
9
|
from enum import IntEnum
|
10
|
-
import zipfile, io
|
10
|
+
import zipfile, io, asyncio
|
11
11
|
|
12
|
-
import aiosqlite
|
12
|
+
import aiosqlite, aiofiles
|
13
|
+
import aiofiles.os
|
13
14
|
from asyncio import Lock
|
14
15
|
|
15
|
-
from .config import DATA_HOME
|
16
|
+
from .config import DATA_HOME, LARGE_BLOB_DIR
|
16
17
|
from .log import get_logger
|
17
18
|
from .utils import decode_uri_compnents
|
18
19
|
from .error import *
|
@@ -47,6 +48,7 @@ class DBConnBase(ABC):
|
|
47
48
|
global _g_conn
|
48
49
|
if _g_conn is None:
|
49
50
|
_g_conn = await aiosqlite.connect(DATA_HOME / 'lfss.db')
|
51
|
+
await _g_conn.execute('PRAGMA journal_mode=memory')
|
50
52
|
|
51
53
|
async def commit(self):
|
52
54
|
await self.conn.commit()
|
@@ -190,15 +192,19 @@ class FileRecord:
|
|
190
192
|
create_time: str
|
191
193
|
access_time: str
|
192
194
|
permission: FileReadPermission
|
195
|
+
external: bool
|
193
196
|
|
194
197
|
def __str__(self):
|
195
198
|
return f"File {self.url} (owner={self.owner_id}, created at {self.create_time}, accessed at {self.access_time}, " + \
|
196
|
-
f"file_id={self.file_id}, permission={self.permission}, size={self.file_size})"
|
199
|
+
f"file_id={self.file_id}, permission={self.permission}, size={self.file_size}, external={self.external})"
|
197
200
|
|
198
201
|
@dataclasses.dataclass
|
199
202
|
class DirectoryRecord:
|
200
203
|
url: str
|
201
204
|
size: int
|
205
|
+
create_time: str = ""
|
206
|
+
update_time: str = ""
|
207
|
+
access_time: str = ""
|
202
208
|
|
203
209
|
def __str__(self):
|
204
210
|
return f"Directory {self.url} (size={self.size})"
|
@@ -224,7 +230,9 @@ class FileConn(DBConnBase):
|
|
224
230
|
file_size INTEGER,
|
225
231
|
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
226
232
|
access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
227
|
-
permission INTEGER DEFAULT 0
|
233
|
+
permission INTEGER DEFAULT 0,
|
234
|
+
external BOOLEAN DEFAULT FALSE,
|
235
|
+
FOREIGN KEY(owner_id) REFERENCES user(id)
|
228
236
|
)
|
229
237
|
''')
|
230
238
|
await self.conn.execute('''
|
@@ -256,6 +264,16 @@ class FileConn(DBConnBase):
|
|
256
264
|
size = await cursor.fetchone()
|
257
265
|
if size is not None and size[0] is not None:
|
258
266
|
await self._user_size_inc(r[0], size[0])
|
267
|
+
|
268
|
+
# backward compatibility, since 0.5.0
|
269
|
+
# 'external' means the file is not stored in the database, but in the external storage
|
270
|
+
async with self.conn.execute("SELECT * FROM fmeta") as cursor:
|
271
|
+
res = await cursor.fetchone()
|
272
|
+
if res and len(res) < 8:
|
273
|
+
self.logger.info("Updating fmeta table")
|
274
|
+
await self.conn.execute('''
|
275
|
+
ALTER TABLE fmeta ADD COLUMN external BOOLEAN DEFAULT FALSE
|
276
|
+
''')
|
259
277
|
|
260
278
|
return self
|
261
279
|
|
@@ -278,7 +296,7 @@ class FileConn(DBConnBase):
|
|
278
296
|
res = await cursor.fetchall()
|
279
297
|
return [self.parse_record(r) for r in res]
|
280
298
|
|
281
|
-
async def
|
299
|
+
async def get_path_file_records(self, url: str) -> list[FileRecord]:
|
282
300
|
async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
|
283
301
|
res = await cursor.fetchall()
|
284
302
|
return [self.parse_record(r) for r in res]
|
@@ -348,10 +366,27 @@ class FileConn(DBConnBase):
|
|
348
366
|
) as cursor:
|
349
367
|
res = await cursor.fetchall()
|
350
368
|
dirs_str = [r[0] + '/' for r in res if r[0] != '/']
|
351
|
-
|
352
|
-
|
369
|
+
async def get_dir(dir_url):
|
370
|
+
return DirectoryRecord(dir_url, -1)
|
371
|
+
dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
|
353
372
|
return PathContents(dirs, files)
|
354
373
|
|
374
|
+
async def get_path_record(self, url: str) -> Optional[DirectoryRecord]:
|
375
|
+
assert url.endswith('/'), "Path must end with /"
|
376
|
+
async with self.conn.execute("""
|
377
|
+
SELECT MIN(create_time) as create_time,
|
378
|
+
MAX(create_time) as update_time,
|
379
|
+
MAX(access_time) as access_time
|
380
|
+
FROM fmeta
|
381
|
+
WHERE url LIKE ?
|
382
|
+
""", (url + '%', )) as cursor:
|
383
|
+
result = await cursor.fetchone()
|
384
|
+
if result is None or any(val is None for val in result):
|
385
|
+
raise PathNotFoundError(f"Path {url} not found")
|
386
|
+
create_time, update_time, access_time = result
|
387
|
+
p_size = await self.path_size(url, include_subpath=True)
|
388
|
+
return DirectoryRecord(url, p_size, create_time=create_time, update_time=update_time, access_time=access_time)
|
389
|
+
|
355
390
|
async def user_size(self, user_id: int) -> int:
|
356
391
|
async with self.conn.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, )) as cursor:
|
357
392
|
res = await cursor.fetchone()
|
@@ -383,7 +418,8 @@ class FileConn(DBConnBase):
|
|
383
418
|
owner_id: Optional[int] = None,
|
384
419
|
file_id: Optional[str] = None,
|
385
420
|
file_size: Optional[int] = None,
|
386
|
-
permission: Optional[ FileReadPermission ] = None
|
421
|
+
permission: Optional[ FileReadPermission ] = None,
|
422
|
+
external: Optional[bool] = None
|
387
423
|
):
|
388
424
|
|
389
425
|
old = await self.get_file_record(url)
|
@@ -392,6 +428,7 @@ class FileConn(DBConnBase):
|
|
392
428
|
# should delete the old blob if file_id is changed
|
393
429
|
assert file_id is None, "Cannot update file id"
|
394
430
|
assert file_size is None, "Cannot update file size"
|
431
|
+
assert external is None, "Cannot update external"
|
395
432
|
|
396
433
|
if owner_id is None: owner_id = old.owner_id
|
397
434
|
if permission is None: permission = old.permission
|
@@ -402,13 +439,13 @@ class FileConn(DBConnBase):
|
|
402
439
|
""", (owner_id, int(permission), url))
|
403
440
|
self.logger.info(f"File {url} updated")
|
404
441
|
else:
|
405
|
-
self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}")
|
442
|
+
self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}, external={external}")
|
406
443
|
if permission is None:
|
407
444
|
permission = FileReadPermission.UNSET
|
408
|
-
assert owner_id is not None and file_id is not None and file_size is not None
|
445
|
+
assert owner_id is not None and file_id is not None and file_size is not None and external is not None
|
409
446
|
await self.conn.execute(
|
410
|
-
"INSERT INTO fmeta (url, owner_id, file_id, file_size, permission) VALUES (?, ?, ?, ?, ?)",
|
411
|
-
(url, owner_id, file_id, file_size, int(permission))
|
447
|
+
"INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external) VALUES (?, ?, ?, ?, ?, ?)",
|
448
|
+
(url, owner_id, file_id, file_size, int(permission), external)
|
412
449
|
)
|
413
450
|
await self._user_size_inc(owner_id, file_size)
|
414
451
|
self.logger.info(f"File {url} created")
|
@@ -465,6 +502,20 @@ class FileConn(DBConnBase):
|
|
465
502
|
async def set_file_blob(self, file_id: str, blob: bytes):
|
466
503
|
await self.conn.execute("INSERT OR REPLACE INTO fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
|
467
504
|
|
505
|
+
@atomic
|
506
|
+
async def set_file_blob_external(self, file_id: str, stream: AsyncIterable[bytes])->int:
|
507
|
+
size_sum = 0
|
508
|
+
try:
|
509
|
+
async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'wb') as f:
|
510
|
+
async for chunk in stream:
|
511
|
+
size_sum += len(chunk)
|
512
|
+
await f.write(chunk)
|
513
|
+
except Exception as e:
|
514
|
+
if (LARGE_BLOB_DIR / file_id).exists():
|
515
|
+
await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
|
516
|
+
raise
|
517
|
+
return size_sum
|
518
|
+
|
468
519
|
async def get_file_blob(self, file_id: str) -> Optional[bytes]:
|
469
520
|
async with self.conn.execute("SELECT data FROM fdata WHERE file_id = ?", (file_id, )) as cursor:
|
470
521
|
res = await cursor.fetchone()
|
@@ -472,6 +523,17 @@ class FileConn(DBConnBase):
|
|
472
523
|
return None
|
473
524
|
return res[0]
|
474
525
|
|
526
|
+
async def get_file_blob_external(self, file_id: str) -> AsyncIterable[bytes]:
|
527
|
+
assert (LARGE_BLOB_DIR / file_id).exists(), f"File {file_id} not found"
|
528
|
+
async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'rb') as f:
|
529
|
+
async for chunk in f:
|
530
|
+
yield chunk
|
531
|
+
|
532
|
+
@atomic
|
533
|
+
async def delete_file_blob_external(self, file_id: str):
|
534
|
+
if (LARGE_BLOB_DIR / file_id).exists():
|
535
|
+
await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
|
536
|
+
|
475
537
|
@atomic
|
476
538
|
async def delete_file_blob(self, file_id: str):
|
477
539
|
await self.conn.execute("DELETE FROM fdata WHERE file_id = ?", (file_id, ))
|
@@ -543,9 +605,15 @@ class Database:
|
|
543
605
|
if _g_conn is not None:
|
544
606
|
await _g_conn.rollback()
|
545
607
|
|
546
|
-
async def save_file(
|
608
|
+
async def save_file(
|
609
|
+
self, u: int | str, url: str,
|
610
|
+
blob: bytes | AsyncIterable[bytes],
|
611
|
+
permission: FileReadPermission = FileReadPermission.UNSET
|
612
|
+
):
|
613
|
+
"""
|
614
|
+
if file_size is not provided, the blob must be bytes
|
615
|
+
"""
|
547
616
|
validate_url(url)
|
548
|
-
assert isinstance(blob, bytes), "blob must be bytes"
|
549
617
|
|
550
618
|
user = await get_user(self, u)
|
551
619
|
if user is None:
|
@@ -562,25 +630,48 @@ class Database:
|
|
562
630
|
if await get_user(self, first_component) is None:
|
563
631
|
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
564
632
|
|
565
|
-
# check if fize_size is within limit
|
566
|
-
file_size = len(blob)
|
567
633
|
user_size_used = await self.file.user_size(user.id)
|
568
|
-
if
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
634
|
+
if isinstance(blob, bytes):
|
635
|
+
file_size = len(blob)
|
636
|
+
if user_size_used + file_size > user.max_storage:
|
637
|
+
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
638
|
+
f_id = uuid.uuid4().hex
|
639
|
+
async with transaction(self):
|
640
|
+
await self.file.set_file_blob(f_id, blob)
|
641
|
+
await self.file.set_file_record(
|
642
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
643
|
+
permission=permission, external=False)
|
644
|
+
await self.user.set_active(user.username)
|
645
|
+
else:
|
646
|
+
assert isinstance(blob, AsyncIterable)
|
647
|
+
async with transaction(self):
|
648
|
+
f_id = uuid.uuid4().hex
|
649
|
+
file_size = await self.file.set_file_blob_external(f_id, blob)
|
650
|
+
if user_size_used + file_size > user.max_storage:
|
651
|
+
await self.file.delete_file_blob_external(f_id)
|
652
|
+
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
653
|
+
await self.file.set_file_record(
|
654
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
655
|
+
permission=permission, external=True)
|
656
|
+
await self.user.set_active(user.username)
|
657
|
+
|
658
|
+
async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
|
659
|
+
validate_url(url)
|
660
|
+
r = await self.file.get_file_record(url)
|
661
|
+
if r is None:
|
662
|
+
raise FileNotFoundError(f"File {url} not found")
|
663
|
+
if not r.external:
|
664
|
+
raise ValueError(f"File {url} is not stored externally, should use read_file instead")
|
665
|
+
return self.file.get_file_blob_external(r.file_id)
|
576
666
|
|
577
|
-
# async def read_file_stream(self, url: str): ...
|
578
667
|
async def read_file(self, url: str) -> bytes:
|
579
668
|
validate_url(url)
|
580
669
|
|
581
670
|
r = await self.file.get_file_record(url)
|
582
671
|
if r is None:
|
583
672
|
raise FileNotFoundError(f"File {url} not found")
|
673
|
+
if r.external:
|
674
|
+
raise ValueError(f"File {url} is stored externally, should use read_file_stream instead")
|
584
675
|
|
585
676
|
f_id = r.file_id
|
586
677
|
blob = await self.file.get_file_blob(f_id)
|
@@ -600,8 +691,11 @@ class Database:
|
|
600
691
|
if r is None:
|
601
692
|
return None
|
602
693
|
f_id = r.file_id
|
603
|
-
await self.file.delete_file_blob(f_id)
|
604
694
|
await self.file.delete_file_record(url)
|
695
|
+
if r.external:
|
696
|
+
await self.file.delete_file_blob_external(f_id)
|
697
|
+
else:
|
698
|
+
await self.file.delete_file_blob(f_id)
|
605
699
|
return r
|
606
700
|
|
607
701
|
async def move_file(self, old_url: str, new_url: str):
|
@@ -611,17 +705,33 @@ class Database:
|
|
611
705
|
async with transaction(self):
|
612
706
|
await self.file.move_file(old_url, new_url)
|
613
707
|
|
708
|
+
async def __batch_delete_file_blobs(self, file_records: list[FileRecord], batch_size: int = 512):
|
709
|
+
# https://github.com/langchain-ai/langchain/issues/10321
|
710
|
+
internal_ids = []
|
711
|
+
external_ids = []
|
712
|
+
for r in file_records:
|
713
|
+
if r.external:
|
714
|
+
external_ids.append(r.file_id)
|
715
|
+
else:
|
716
|
+
internal_ids.append(r.file_id)
|
717
|
+
|
718
|
+
for i in range(0, len(internal_ids), batch_size):
|
719
|
+
await self.file.delete_file_blobs([r for r in internal_ids[i:i+batch_size]])
|
720
|
+
for i in range(0, len(external_ids)):
|
721
|
+
await self.file.delete_file_blob_external(external_ids[i])
|
722
|
+
|
723
|
+
|
614
724
|
async def delete_path(self, url: str):
|
615
725
|
validate_url(url, is_file=False)
|
616
726
|
|
617
727
|
async with transaction(self):
|
618
|
-
records = await self.file.
|
728
|
+
records = await self.file.get_path_file_records(url)
|
619
729
|
if not records:
|
620
730
|
return None
|
621
|
-
await self.
|
731
|
+
await self.__batch_delete_file_blobs(records)
|
622
732
|
await self.file.delete_path_records(url)
|
623
733
|
return records
|
624
|
-
|
734
|
+
|
625
735
|
async def delete_user(self, u: str | int):
|
626
736
|
user = await get_user(self, u)
|
627
737
|
if user is None:
|
@@ -629,11 +739,11 @@ class Database:
|
|
629
739
|
|
630
740
|
async with transaction(self):
|
631
741
|
records = await self.file.get_user_file_records(user.id)
|
632
|
-
await self.
|
742
|
+
await self.__batch_delete_file_blobs(records)
|
633
743
|
await self.file.delete_user_file_records(user.id)
|
634
744
|
await self.user.delete_user(user.username)
|
635
745
|
|
636
|
-
async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes]]:
|
746
|
+
async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes | AsyncIterable[bytes]]]:
|
637
747
|
if urls is None:
|
638
748
|
urls = [r.url for r in await self.file.list_path(top_url, flat=True)]
|
639
749
|
|
@@ -644,10 +754,13 @@ class Database:
|
|
644
754
|
if r is None:
|
645
755
|
continue
|
646
756
|
f_id = r.file_id
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
757
|
+
if r.external:
|
758
|
+
blob = self.file.get_file_blob_external(f_id)
|
759
|
+
else:
|
760
|
+
blob = await self.file.get_file_blob(f_id)
|
761
|
+
if blob is None:
|
762
|
+
self.logger.warning(f"Blob not found for {url}")
|
763
|
+
continue
|
651
764
|
yield r, blob
|
652
765
|
|
653
766
|
async def zip_path(self, top_url: str, urls: Optional[list[str]]) -> io.BytesIO:
|
@@ -658,7 +771,12 @@ class Database:
|
|
658
771
|
async for (r, blob) in self.iter_path(top_url, urls):
|
659
772
|
rel_path = r.url[len(top_url):]
|
660
773
|
rel_path = decode_uri_compnents(rel_path)
|
661
|
-
|
774
|
+
if r.external:
|
775
|
+
assert isinstance(blob, AsyncIterable)
|
776
|
+
zf.writestr(rel_path, b''.join([chunk async for chunk in blob]))
|
777
|
+
else:
|
778
|
+
assert isinstance(blob, bytes)
|
779
|
+
zf.writestr(rel_path, blob)
|
662
780
|
buffer.seek(0)
|
663
781
|
return buffer
|
664
782
|
|
@@ -2,6 +2,7 @@ from typing import Optional
|
|
2
2
|
from functools import wraps
|
3
3
|
|
4
4
|
from fastapi import FastAPI, APIRouter, Depends, Request, Response
|
5
|
+
from fastapi.responses import StreamingResponse
|
5
6
|
from fastapi.exceptions import HTTPException
|
6
7
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
7
8
|
from fastapi.middleware.cors import CORSMiddleware
|
@@ -14,7 +15,7 @@ from contextlib import asynccontextmanager
|
|
14
15
|
from .error import *
|
15
16
|
from .log import get_logger
|
16
17
|
from .stat import RequestDB
|
17
|
-
from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES
|
18
|
+
from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_BLOB_DIR, LARGE_FILE_BYTES
|
18
19
|
from .utils import ensure_uri_compnents, format_last_modified, now_stamp
|
19
20
|
from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission
|
20
21
|
|
@@ -141,19 +142,32 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
|
|
141
142
|
|
142
143
|
fname = path.split("/")[-1]
|
143
144
|
async def send(media_type: Optional[str] = None, disposition = "attachment"):
|
144
|
-
|
145
|
-
|
146
|
-
media_type
|
147
|
-
|
148
|
-
media_type
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
145
|
+
if not file_record.external:
|
146
|
+
fblob = await conn.read_file(path)
|
147
|
+
if media_type is None:
|
148
|
+
media_type, _ = mimetypes.guess_type(fname)
|
149
|
+
if media_type is None:
|
150
|
+
media_type = mimesniff.what(fblob)
|
151
|
+
return Response(
|
152
|
+
content=fblob, media_type=media_type, headers={
|
153
|
+
"Content-Disposition": f"{disposition}; filename={fname}",
|
154
|
+
"Content-Length": str(len(fblob)),
|
155
|
+
"Last-Modified": format_last_modified(file_record.create_time)
|
156
|
+
}
|
157
|
+
)
|
158
|
+
|
159
|
+
else:
|
160
|
+
if media_type is None:
|
161
|
+
media_type, _ = mimetypes.guess_type(fname)
|
162
|
+
if media_type is None:
|
163
|
+
media_type = mimesniff.what(str((LARGE_BLOB_DIR / file_record.file_id).absolute()))
|
164
|
+
return StreamingResponse(
|
165
|
+
await conn.read_file_stream(path), media_type=media_type, headers={
|
166
|
+
"Content-Disposition": f"{disposition}; filename={fname}",
|
167
|
+
"Content-Length": str(file_record.file_size),
|
168
|
+
"Last-Modified": format_last_modified(file_record.create_time)
|
169
|
+
}
|
170
|
+
)
|
157
171
|
|
158
172
|
if download:
|
159
173
|
return await send('application/octet-stream', "attachment")
|
@@ -198,20 +212,25 @@ async def put_file(
|
|
198
212
|
logger.debug(f"Content-Type: {content_type}")
|
199
213
|
if content_type == "application/json":
|
200
214
|
body = await request.json()
|
201
|
-
|
215
|
+
blobs = json.dumps(body).encode('utf-8')
|
202
216
|
elif content_type == "application/x-www-form-urlencoded":
|
203
217
|
# may not work...
|
204
218
|
body = await request.form()
|
205
219
|
file = body.get("file")
|
206
220
|
if isinstance(file, str) or file is None:
|
207
221
|
raise HTTPException(status_code=400, detail="Invalid form data, file required")
|
208
|
-
|
222
|
+
blobs = await file.read()
|
209
223
|
elif content_type == "application/octet-stream":
|
210
|
-
|
211
|
-
|
224
|
+
blobs = await request.body()
|
225
|
+
else:
|
226
|
+
blobs = await request.body()
|
227
|
+
if len(blobs) > LARGE_FILE_BYTES:
|
228
|
+
async def blob_reader():
|
229
|
+
for b in range(0, len(blobs), 4096):
|
230
|
+
yield blobs[b:b+4096]
|
231
|
+
await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
|
212
232
|
else:
|
213
|
-
|
214
|
-
await conn.save_file(user.id, path, body, permission = FileReadPermission(permission))
|
233
|
+
await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
|
215
234
|
|
216
235
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
|
217
236
|
if exists_flag:
|
@@ -289,19 +308,18 @@ async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
|
|
289
308
|
}
|
290
309
|
)
|
291
310
|
|
292
|
-
@router_api.get("/
|
311
|
+
@router_api.get("/meta")
|
293
312
|
@handle_exception
|
294
313
|
async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user)):
|
295
314
|
logger.info(f"GET meta({path}), user: {user.username}")
|
296
|
-
if path.endswith("/"):
|
297
|
-
raise HTTPException(status_code=400, detail="Invalid path")
|
298
315
|
path = ensure_uri_compnents(path)
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
316
|
+
get_fn = conn.file.get_file_record if not path.endswith("/") else conn.file.get_path_record
|
317
|
+
record = await get_fn(path)
|
318
|
+
if not record:
|
319
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
320
|
+
return record
|
303
321
|
|
304
|
-
@router_api.post("/
|
322
|
+
@router_api.post("/meta")
|
305
323
|
@handle_exception
|
306
324
|
async def update_file_meta(
|
307
325
|
path: str,
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "lfss"
|
3
|
-
version = "0.
|
3
|
+
version = "0.5.0"
|
4
4
|
description = "Lightweight file storage service"
|
5
5
|
authors = ["li, mengxun <limengxun45@outlook.com>"]
|
6
6
|
readme = "Readme.md"
|
@@ -12,6 +12,7 @@ include = ["Readme.md", "docs/*", "frontend/*"]
|
|
12
12
|
python = ">=3.9"
|
13
13
|
fastapi = "0.*"
|
14
14
|
aiosqlite = "0.*"
|
15
|
+
aiofiles = "23.*"
|
15
16
|
mimesniff = "1.*"
|
16
17
|
|
17
18
|
[tool.poetry.scripts]
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|