lfss 0.4.1__tar.gz → 0.5.1__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.1}/PKG-INFO +2 -1
- {lfss-0.4.1 → lfss-0.5.1}/frontend/api.js +9 -7
- {lfss-0.4.1 → lfss-0.5.1}/frontend/scripts.js +29 -11
- {lfss-0.4.1 → lfss-0.5.1}/frontend/styles.css +1 -0
- {lfss-0.4.1 → lfss-0.5.1}/frontend/utils.js +6 -0
- lfss-0.5.1/lfss/cli/balance.py +111 -0
- {lfss-0.4.1 → lfss-0.5.1}/lfss/cli/cli.py +9 -5
- {lfss-0.4.1 → lfss-0.5.1}/lfss/cli/user.py +2 -0
- {lfss-0.4.1 → lfss-0.5.1}/lfss/client/__init__.py +2 -0
- {lfss-0.4.1 → lfss-0.5.1}/lfss/client/api.py +11 -8
- lfss-0.5.1/lfss/sql/init.sql +38 -0
- lfss-0.5.1/lfss/sql/pragma.sql +5 -0
- lfss-0.5.1/lfss/src/config.py +16 -0
- {lfss-0.4.1 → lfss-0.5.1}/lfss/src/database.py +163 -85
- {lfss-0.4.1 → lfss-0.5.1}/lfss/src/error.py +2 -0
- {lfss-0.4.1 → lfss-0.5.1}/lfss/src/server.py +54 -31
- {lfss-0.4.1 → lfss-0.5.1}/lfss/src/stat.py +1 -1
- {lfss-0.4.1 → lfss-0.5.1}/pyproject.toml +4 -2
- lfss-0.4.1/lfss/src/config.py +0 -12
- {lfss-0.4.1 → lfss-0.5.1}/Readme.md +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/docs/Known_issues.md +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/docs/Permission.md +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/frontend/index.html +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/frontend/popup.css +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/frontend/popup.js +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/lfss/cli/panel.py +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/lfss/cli/serve.py +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/lfss/src/__init__.py +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/lfss/src/log.py +0 -0
- {lfss-0.4.1 → lfss-0.5.1}/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.1
|
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
|
@@ -55,13 +57,13 @@ export default class Connector {
|
|
55
57
|
* @returns {Promise<string>} - the promise of the request, the url of the file
|
56
58
|
*/
|
57
59
|
async put(path, file, {
|
58
|
-
|
60
|
+
conflict = 'abort',
|
59
61
|
permission = 0
|
60
62
|
} = {}){
|
61
63
|
if (path.startsWith('/')){ path = path.slice(1); }
|
62
64
|
const fileBytes = await file.arrayBuffer();
|
63
65
|
const dst = new URL(this.config.endpoint + '/' + path);
|
64
|
-
dst.searchParams.append('
|
66
|
+
dst.searchParams.append('conflict', conflict);
|
65
67
|
dst.searchParams.append('permission', permission);
|
66
68
|
const res = await fetch(dst.toString(), {
|
67
69
|
method: 'PUT',
|
@@ -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
|
}
|
@@ -172,7 +176,7 @@ Are you sure you want to proceed?
|
|
172
176
|
async function uploadFile(...args){
|
173
177
|
const [file, path] = args;
|
174
178
|
try{
|
175
|
-
await conn.put(path, file, {
|
179
|
+
await conn.put(path, file, {conflict: 'overwrite'});
|
176
180
|
}
|
177
181
|
catch (err){
|
178
182
|
showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
|
@@ -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', ' ');
|
@@ -0,0 +1,111 @@
|
|
1
|
+
"""
|
2
|
+
Balance the storage by ensuring that large file thresholds are met.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from lfss.src.config import DATA_HOME, LARGE_BLOB_DIR, LARGE_FILE_BYTES
|
6
|
+
import argparse, time
|
7
|
+
from functools import wraps
|
8
|
+
from asyncio import Semaphore
|
9
|
+
import aiosqlite, aiofiles, asyncio
|
10
|
+
|
11
|
+
sem = Semaphore(1)
|
12
|
+
db_file = DATA_HOME / 'lfss.db'
|
13
|
+
|
14
|
+
def _get_sem():
|
15
|
+
return sem
|
16
|
+
|
17
|
+
def barriered(func):
|
18
|
+
@wraps(func)
|
19
|
+
async def wrapper(*args, **kwargs):
|
20
|
+
async with _get_sem():
|
21
|
+
return await func(*args, **kwargs)
|
22
|
+
return wrapper
|
23
|
+
|
24
|
+
@barriered
|
25
|
+
async def move_to_external(f_id: str, flag: str = ''):
|
26
|
+
async with aiosqlite.connect(db_file, timeout = 60) as c:
|
27
|
+
async with c.execute( "SELECT data FROM fdata WHERE file_id = ?", (f_id,)) as cursor:
|
28
|
+
blob_row = await cursor.fetchone()
|
29
|
+
if blob_row is None:
|
30
|
+
print(f"{flag}File {f_id} not found in fdata")
|
31
|
+
return
|
32
|
+
await c.execute("BEGIN")
|
33
|
+
blob: bytes = blob_row[0]
|
34
|
+
try:
|
35
|
+
async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'wb') as f:
|
36
|
+
await f.write(blob)
|
37
|
+
await c.execute( "UPDATE fmeta SET external = 1 WHERE file_id = ?", (f_id,))
|
38
|
+
await c.execute( "DELETE FROM fdata WHERE file_id = ?", (f_id,))
|
39
|
+
await c.commit()
|
40
|
+
print(f"{flag}Moved {f_id} to external storage")
|
41
|
+
except Exception as e:
|
42
|
+
await c.rollback()
|
43
|
+
print(f"{flag}Error moving {f_id}: {e}")
|
44
|
+
|
45
|
+
if isinstance(e, KeyboardInterrupt):
|
46
|
+
raise e
|
47
|
+
|
48
|
+
@barriered
|
49
|
+
async def move_to_internal(f_id: str, flag: str = ''):
|
50
|
+
async with aiosqlite.connect(db_file, timeout = 60) as c:
|
51
|
+
if not (LARGE_BLOB_DIR / f_id).exists():
|
52
|
+
print(f"{flag}File {f_id} not found in external storage")
|
53
|
+
return
|
54
|
+
async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'rb') as f:
|
55
|
+
blob = await f.read()
|
56
|
+
|
57
|
+
await c.execute("BEGIN")
|
58
|
+
try:
|
59
|
+
await c.execute("INSERT INTO fdata (file_id, data) VALUES (?, ?)", (f_id, blob))
|
60
|
+
await c.execute("UPDATE fmeta SET external = 0 WHERE file_id = ?", (f_id,))
|
61
|
+
await c.commit()
|
62
|
+
(LARGE_BLOB_DIR / f_id).unlink(missing_ok=True)
|
63
|
+
print(f"{flag}Moved {f_id} to internal storage")
|
64
|
+
except Exception as e:
|
65
|
+
await c.rollback()
|
66
|
+
print(f"{flag}Error moving {f_id}: {e}")
|
67
|
+
if isinstance(e, KeyboardInterrupt):
|
68
|
+
raise e
|
69
|
+
|
70
|
+
|
71
|
+
async def _main():
|
72
|
+
|
73
|
+
tasks = []
|
74
|
+
start_time = time.time()
|
75
|
+
async with aiosqlite.connect(db_file) as conn:
|
76
|
+
exceeded_rows = await (await conn.execute(
|
77
|
+
"SELECT file_id FROM fmeta WHERE file_size > ? AND external = 0",
|
78
|
+
(LARGE_FILE_BYTES,)
|
79
|
+
)).fetchall()
|
80
|
+
|
81
|
+
for i in range(0, len(exceeded_rows)):
|
82
|
+
row = exceeded_rows[i]
|
83
|
+
f_id = row[0]
|
84
|
+
tasks.append(move_to_external(f_id, flag=f"[e-{i+1}/{len(exceeded_rows)}] "))
|
85
|
+
|
86
|
+
async with aiosqlite.connect(db_file) as conn:
|
87
|
+
under_rows = await (await conn.execute(
|
88
|
+
"SELECT file_id, file_size, external FROM fmeta WHERE file_size <= ? AND external = 1",
|
89
|
+
(LARGE_FILE_BYTES,)
|
90
|
+
)).fetchall()
|
91
|
+
|
92
|
+
for i in range(0, len(under_rows)):
|
93
|
+
row = under_rows[i]
|
94
|
+
f_id = row[0]
|
95
|
+
tasks.append(move_to_internal(f_id, flag=f"[i-{i+1}/{len(under_rows)}] "))
|
96
|
+
|
97
|
+
await asyncio.gather(*tasks)
|
98
|
+
end_time = time.time()
|
99
|
+
print(f"Balancing complete, took {end_time - start_time:.2f} seconds. "
|
100
|
+
f"{len(exceeded_rows)} files moved to external storage, {len(under_rows)} files moved to internal storage.")
|
101
|
+
|
102
|
+
def main():
|
103
|
+
global sem
|
104
|
+
parser = argparse.ArgumentParser(description="Balance the storage by ensuring that large file thresholds are met.")
|
105
|
+
parser.add_argument("-j", "--jobs", type=int, default=2, help="Number of concurrent jobs")
|
106
|
+
args = parser.parse_args()
|
107
|
+
sem = Semaphore(args.jobs)
|
108
|
+
asyncio.run(_main())
|
109
|
+
|
110
|
+
if __name__ == '__main__':
|
111
|
+
main()
|
@@ -13,8 +13,8 @@ def parse_arguments():
|
|
13
13
|
sp_upload.add_argument("src", help="Source file or directory", type=str)
|
14
14
|
sp_upload.add_argument("dst", help="Destination path", type=str)
|
15
15
|
sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
|
16
|
-
sp_upload.add_argument("--interval", type=float, default=0, help="Interval between
|
17
|
-
sp_upload.add_argument("--overwrite",
|
16
|
+
sp_upload.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory upload")
|
17
|
+
sp_upload.add_argument("--conflict", choices=["overwrite", "abort", "skip"], default="abort", help="Conflict resolution")
|
18
18
|
sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
|
19
19
|
sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
|
20
20
|
|
@@ -26,21 +26,25 @@ 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,
|
33
33
|
n_reties=args.retries,
|
34
34
|
interval=args.interval,
|
35
|
-
|
35
|
+
conflict=args.conflict,
|
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(
|
41
45
|
args.dst,
|
42
46
|
f.read(),
|
43
|
-
|
47
|
+
conflict=args.conflict,
|
44
48
|
permission=args.permission
|
45
49
|
)
|
46
50
|
else:
|
@@ -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')
|
@@ -34,13 +34,13 @@ class Connector:
|
|
34
34
|
return response
|
35
35
|
return f
|
36
36
|
|
37
|
-
def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0,
|
37
|
+
def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip'] = 'abort'):
|
38
38
|
"""Uploads a file to the specified path."""
|
39
39
|
if path.startswith('/'):
|
40
40
|
path = path[1:]
|
41
41
|
response = self._fetch('PUT', path, search_params={
|
42
42
|
'permission': int(permission),
|
43
|
-
'
|
43
|
+
'conflict': conflict
|
44
44
|
})(
|
45
45
|
data=file_data,
|
46
46
|
headers={'Content-Type': 'application/octet-stream'}
|
@@ -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
|
|
@@ -0,0 +1,38 @@
|
|
1
|
+
CREATE TABLE IF NOT EXISTS user (
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
3
|
+
username VARCHAR(255) UNIQUE NOT NULL,
|
4
|
+
credential VARCHAR(255) NOT NULL,
|
5
|
+
is_admin BOOLEAN DEFAULT FALSE,
|
6
|
+
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
7
|
+
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
8
|
+
max_storage INTEGER DEFAULT 1073741824,
|
9
|
+
permission INTEGER DEFAULT 0
|
10
|
+
);
|
11
|
+
|
12
|
+
CREATE TABLE IF NOT EXISTS fmeta (
|
13
|
+
url VARCHAR(512) PRIMARY KEY,
|
14
|
+
owner_id INTEGER NOT NULL,
|
15
|
+
file_id VARCHAR(256) NOT NULL,
|
16
|
+
file_size INTEGER,
|
17
|
+
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
18
|
+
access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
19
|
+
permission INTEGER DEFAULT 0,
|
20
|
+
external BOOLEAN DEFAULT FALSE,
|
21
|
+
FOREIGN KEY(owner_id) REFERENCES user(id)
|
22
|
+
);
|
23
|
+
|
24
|
+
CREATE TABLE IF NOT EXISTS fdata (
|
25
|
+
file_id VARCHAR(256) PRIMARY KEY,
|
26
|
+
data BLOB
|
27
|
+
);
|
28
|
+
|
29
|
+
CREATE TABLE IF NOT EXISTS usize (
|
30
|
+
user_id INTEGER PRIMARY KEY,
|
31
|
+
size INTEGER DEFAULT 0
|
32
|
+
);
|
33
|
+
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url);
|
35
|
+
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
|
37
|
+
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential);
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
import os
|
3
|
+
|
4
|
+
__default_dir = '.storage_data'
|
5
|
+
|
6
|
+
DATA_HOME = Path(os.environ.get('LFSS_DATA', __default_dir))
|
7
|
+
if not DATA_HOME.exists():
|
8
|
+
DATA_HOME.mkdir()
|
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)
|
12
|
+
|
13
|
+
# https://sqlite.org/fasterthanfs.html
|
14
|
+
LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
|
15
|
+
MAX_FILE_BYTES = 1024 * 1024 * 1024 # 1GB
|
16
|
+
MAX_BUNDLE_BYTES = 1024 * 1024 * 1024 # 1GB
|
@@ -3,16 +3,18 @@ from typing import Optional, overload, Literal, AsyncIterable
|
|
3
3
|
from abc import ABC, abstractmethod
|
4
4
|
|
5
5
|
import urllib.parse
|
6
|
+
from pathlib import Path
|
6
7
|
import dataclasses, hashlib, uuid
|
7
8
|
from contextlib import asynccontextmanager
|
8
9
|
from functools import wraps
|
9
10
|
from enum import IntEnum
|
10
|
-
import zipfile, io
|
11
|
+
import zipfile, io, asyncio
|
11
12
|
|
12
|
-
import aiosqlite
|
13
|
+
import aiosqlite, aiofiles
|
14
|
+
import aiofiles.os
|
13
15
|
from asyncio import Lock
|
14
16
|
|
15
|
-
from .config import DATA_HOME
|
17
|
+
from .config import DATA_HOME, LARGE_BLOB_DIR
|
16
18
|
from .log import get_logger
|
17
19
|
from .utils import decode_uri_compnents
|
18
20
|
from .error import *
|
@@ -22,6 +24,15 @@ _g_conn: Optional[aiosqlite.Connection] = None
|
|
22
24
|
def hash_credential(username, password):
|
23
25
|
return hashlib.sha256((username + password).encode()).hexdigest()
|
24
26
|
|
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
|
+
|
25
36
|
_atomic_lock = Lock()
|
26
37
|
def atomic(func):
|
27
38
|
""" Ensure non-reentrancy """
|
@@ -47,6 +58,8 @@ class DBConnBase(ABC):
|
|
47
58
|
global _g_conn
|
48
59
|
if _g_conn is None:
|
49
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')
|
50
63
|
|
51
64
|
async def commit(self):
|
52
65
|
await self.conn.commit()
|
@@ -80,26 +93,6 @@ class UserConn(DBConnBase):
|
|
80
93
|
|
81
94
|
async def init(self):
|
82
95
|
await super().init()
|
83
|
-
# default to 1GB (1024x1024x1024 bytes)
|
84
|
-
await self.conn.execute('''
|
85
|
-
CREATE TABLE IF NOT EXISTS user (
|
86
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
87
|
-
username VARCHAR(255) UNIQUE NOT NULL,
|
88
|
-
credential VARCHAR(255) NOT NULL,
|
89
|
-
is_admin BOOLEAN DEFAULT FALSE,
|
90
|
-
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
91
|
-
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
92
|
-
max_storage INTEGER DEFAULT 1073741824,
|
93
|
-
permission INTEGER DEFAULT 0
|
94
|
-
)
|
95
|
-
''')
|
96
|
-
await self.conn.execute('''
|
97
|
-
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username)
|
98
|
-
''')
|
99
|
-
await self.conn.execute('''
|
100
|
-
CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential)
|
101
|
-
''')
|
102
|
-
|
103
96
|
return self
|
104
97
|
|
105
98
|
async def get_user(self, username: str) -> Optional[UserRecord]:
|
@@ -190,15 +183,19 @@ class FileRecord:
|
|
190
183
|
create_time: str
|
191
184
|
access_time: str
|
192
185
|
permission: FileReadPermission
|
186
|
+
external: bool
|
193
187
|
|
194
188
|
def __str__(self):
|
195
189
|
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})"
|
190
|
+
f"file_id={self.file_id}, permission={self.permission}, size={self.file_size}, external={self.external})"
|
197
191
|
|
198
192
|
@dataclasses.dataclass
|
199
193
|
class DirectoryRecord:
|
200
194
|
url: str
|
201
195
|
size: int
|
196
|
+
create_time: str = ""
|
197
|
+
update_time: str = ""
|
198
|
+
access_time: str = ""
|
202
199
|
|
203
200
|
def __str__(self):
|
204
201
|
return f"Directory {self.url} (size={self.size})"
|
@@ -216,35 +213,6 @@ class FileConn(DBConnBase):
|
|
216
213
|
|
217
214
|
async def init(self):
|
218
215
|
await super().init()
|
219
|
-
await self.conn.execute('''
|
220
|
-
CREATE TABLE IF NOT EXISTS fmeta (
|
221
|
-
url VARCHAR(512) PRIMARY KEY,
|
222
|
-
owner_id INTEGER NOT NULL,
|
223
|
-
file_id VARCHAR(256) NOT NULL,
|
224
|
-
file_size INTEGER,
|
225
|
-
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
226
|
-
access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
227
|
-
permission INTEGER DEFAULT 0
|
228
|
-
)
|
229
|
-
''')
|
230
|
-
await self.conn.execute('''
|
231
|
-
CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url)
|
232
|
-
''')
|
233
|
-
|
234
|
-
await self.conn.execute('''
|
235
|
-
CREATE TABLE IF NOT EXISTS fdata (
|
236
|
-
file_id VARCHAR(256) PRIMARY KEY,
|
237
|
-
data BLOB
|
238
|
-
)
|
239
|
-
''')
|
240
|
-
|
241
|
-
# user file size table
|
242
|
-
await self.conn.execute('''
|
243
|
-
CREATE TABLE IF NOT EXISTS usize (
|
244
|
-
user_id INTEGER PRIMARY KEY,
|
245
|
-
size INTEGER DEFAULT 0
|
246
|
-
)
|
247
|
-
''')
|
248
216
|
# backward compatibility, since 0.2.1
|
249
217
|
async with self.conn.execute("SELECT * FROM user") as cursor:
|
250
218
|
res = await cursor.fetchall()
|
@@ -256,6 +224,16 @@ class FileConn(DBConnBase):
|
|
256
224
|
size = await cursor.fetchone()
|
257
225
|
if size is not None and size[0] is not None:
|
258
226
|
await self._user_size_inc(r[0], size[0])
|
227
|
+
|
228
|
+
# backward compatibility, since 0.5.0
|
229
|
+
# 'external' means the file is not stored in the database, but in the external storage
|
230
|
+
async with self.conn.execute("SELECT * FROM fmeta") as cursor:
|
231
|
+
res = await cursor.fetchone()
|
232
|
+
if res and len(res) < 8:
|
233
|
+
self.logger.info("Updating fmeta table")
|
234
|
+
await self.conn.execute('''
|
235
|
+
ALTER TABLE fmeta ADD COLUMN external BOOLEAN DEFAULT FALSE
|
236
|
+
''')
|
259
237
|
|
260
238
|
return self
|
261
239
|
|
@@ -278,7 +256,7 @@ class FileConn(DBConnBase):
|
|
278
256
|
res = await cursor.fetchall()
|
279
257
|
return [self.parse_record(r) for r in res]
|
280
258
|
|
281
|
-
async def
|
259
|
+
async def get_path_file_records(self, url: str) -> list[FileRecord]:
|
282
260
|
async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
|
283
261
|
res = await cursor.fetchall()
|
284
262
|
return [self.parse_record(r) for r in res]
|
@@ -348,10 +326,27 @@ class FileConn(DBConnBase):
|
|
348
326
|
) as cursor:
|
349
327
|
res = await cursor.fetchall()
|
350
328
|
dirs_str = [r[0] + '/' for r in res if r[0] != '/']
|
351
|
-
|
352
|
-
|
329
|
+
async def get_dir(dir_url):
|
330
|
+
return DirectoryRecord(dir_url, -1)
|
331
|
+
dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
|
353
332
|
return PathContents(dirs, files)
|
354
333
|
|
334
|
+
async def get_path_record(self, url: str) -> Optional[DirectoryRecord]:
|
335
|
+
assert url.endswith('/'), "Path must end with /"
|
336
|
+
async with self.conn.execute("""
|
337
|
+
SELECT MIN(create_time) as create_time,
|
338
|
+
MAX(create_time) as update_time,
|
339
|
+
MAX(access_time) as access_time
|
340
|
+
FROM fmeta
|
341
|
+
WHERE url LIKE ?
|
342
|
+
""", (url + '%', )) as cursor:
|
343
|
+
result = await cursor.fetchone()
|
344
|
+
if result is None or any(val is None for val in result):
|
345
|
+
raise PathNotFoundError(f"Path {url} not found")
|
346
|
+
create_time, update_time, access_time = result
|
347
|
+
p_size = await self.path_size(url, include_subpath=True)
|
348
|
+
return DirectoryRecord(url, p_size, create_time=create_time, update_time=update_time, access_time=access_time)
|
349
|
+
|
355
350
|
async def user_size(self, user_id: int) -> int:
|
356
351
|
async with self.conn.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, )) as cursor:
|
357
352
|
res = await cursor.fetchone()
|
@@ -383,7 +378,8 @@ class FileConn(DBConnBase):
|
|
383
378
|
owner_id: Optional[int] = None,
|
384
379
|
file_id: Optional[str] = None,
|
385
380
|
file_size: Optional[int] = None,
|
386
|
-
permission: Optional[ FileReadPermission ] = None
|
381
|
+
permission: Optional[ FileReadPermission ] = None,
|
382
|
+
external: Optional[bool] = None
|
387
383
|
):
|
388
384
|
|
389
385
|
old = await self.get_file_record(url)
|
@@ -392,6 +388,7 @@ class FileConn(DBConnBase):
|
|
392
388
|
# should delete the old blob if file_id is changed
|
393
389
|
assert file_id is None, "Cannot update file id"
|
394
390
|
assert file_size is None, "Cannot update file size"
|
391
|
+
assert external is None, "Cannot update external"
|
395
392
|
|
396
393
|
if owner_id is None: owner_id = old.owner_id
|
397
394
|
if permission is None: permission = old.permission
|
@@ -402,13 +399,13 @@ class FileConn(DBConnBase):
|
|
402
399
|
""", (owner_id, int(permission), url))
|
403
400
|
self.logger.info(f"File {url} updated")
|
404
401
|
else:
|
405
|
-
self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}")
|
402
|
+
self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}, external={external}")
|
406
403
|
if permission is None:
|
407
404
|
permission = FileReadPermission.UNSET
|
408
|
-
assert owner_id is not None and file_id is not None and file_size is not None
|
405
|
+
assert owner_id is not None and file_id is not None and file_size is not None and external is not None
|
409
406
|
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))
|
407
|
+
"INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external) VALUES (?, ?, ?, ?, ?, ?)",
|
408
|
+
(url, owner_id, file_id, file_size, int(permission), external)
|
412
409
|
)
|
413
410
|
await self._user_size_inc(owner_id, file_size)
|
414
411
|
self.logger.info(f"File {url} created")
|
@@ -465,6 +462,20 @@ class FileConn(DBConnBase):
|
|
465
462
|
async def set_file_blob(self, file_id: str, blob: bytes):
|
466
463
|
await self.conn.execute("INSERT OR REPLACE INTO fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
|
467
464
|
|
465
|
+
@atomic
|
466
|
+
async def set_file_blob_external(self, file_id: str, stream: AsyncIterable[bytes])->int:
|
467
|
+
size_sum = 0
|
468
|
+
try:
|
469
|
+
async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'wb') as f:
|
470
|
+
async for chunk in stream:
|
471
|
+
size_sum += len(chunk)
|
472
|
+
await f.write(chunk)
|
473
|
+
except Exception as e:
|
474
|
+
if (LARGE_BLOB_DIR / file_id).exists():
|
475
|
+
await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
|
476
|
+
raise
|
477
|
+
return size_sum
|
478
|
+
|
468
479
|
async def get_file_blob(self, file_id: str) -> Optional[bytes]:
|
469
480
|
async with self.conn.execute("SELECT data FROM fdata WHERE file_id = ?", (file_id, )) as cursor:
|
470
481
|
res = await cursor.fetchone()
|
@@ -472,6 +483,17 @@ class FileConn(DBConnBase):
|
|
472
483
|
return None
|
473
484
|
return res[0]
|
474
485
|
|
486
|
+
async def get_file_blob_external(self, file_id: str) -> AsyncIterable[bytes]:
|
487
|
+
assert (LARGE_BLOB_DIR / file_id).exists(), f"File {file_id} not found"
|
488
|
+
async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'rb') as f:
|
489
|
+
async for chunk in f:
|
490
|
+
yield chunk
|
491
|
+
|
492
|
+
@atomic
|
493
|
+
async def delete_file_blob_external(self, file_id: str):
|
494
|
+
if (LARGE_BLOB_DIR / file_id).exists():
|
495
|
+
await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
|
496
|
+
|
475
497
|
@atomic
|
476
498
|
async def delete_file_blob(self, file_id: str):
|
477
499
|
await self.conn.execute("DELETE FROM fdata WHERE file_id = ?", (file_id, ))
|
@@ -543,9 +565,15 @@ class Database:
|
|
543
565
|
if _g_conn is not None:
|
544
566
|
await _g_conn.rollback()
|
545
567
|
|
546
|
-
async def save_file(
|
568
|
+
async def save_file(
|
569
|
+
self, u: int | str, url: str,
|
570
|
+
blob: bytes | AsyncIterable[bytes],
|
571
|
+
permission: FileReadPermission = FileReadPermission.UNSET
|
572
|
+
):
|
573
|
+
"""
|
574
|
+
if file_size is not provided, the blob must be bytes
|
575
|
+
"""
|
547
576
|
validate_url(url)
|
548
|
-
assert isinstance(blob, bytes), "blob must be bytes"
|
549
577
|
|
550
578
|
user = await get_user(self, u)
|
551
579
|
if user is None:
|
@@ -562,25 +590,48 @@ class Database:
|
|
562
590
|
if await get_user(self, first_component) is None:
|
563
591
|
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
564
592
|
|
565
|
-
# check if fize_size is within limit
|
566
|
-
file_size = len(blob)
|
567
593
|
user_size_used = await self.file.user_size(user.id)
|
568
|
-
if
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
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(
|
602
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
603
|
+
permission=permission, external=False)
|
604
|
+
await self.user.set_active(user.username)
|
605
|
+
else:
|
606
|
+
assert isinstance(blob, AsyncIterable)
|
607
|
+
async with transaction(self):
|
608
|
+
f_id = uuid.uuid4().hex
|
609
|
+
file_size = await self.file.set_file_blob_external(f_id, blob)
|
610
|
+
if user_size_used + file_size > user.max_storage:
|
611
|
+
await self.file.delete_file_blob_external(f_id)
|
612
|
+
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(
|
614
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
615
|
+
permission=permission, external=True)
|
616
|
+
await self.user.set_active(user.username)
|
617
|
+
|
618
|
+
async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
|
619
|
+
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)
|
576
626
|
|
577
|
-
# async def read_file_stream(self, url: str): ...
|
578
627
|
async def read_file(self, url: str) -> bytes:
|
579
628
|
validate_url(url)
|
580
629
|
|
581
630
|
r = await self.file.get_file_record(url)
|
582
631
|
if r is None:
|
583
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")
|
584
635
|
|
585
636
|
f_id = r.file_id
|
586
637
|
blob = await self.file.get_file_blob(f_id)
|
@@ -600,8 +651,11 @@ class Database:
|
|
600
651
|
if r is None:
|
601
652
|
return None
|
602
653
|
f_id = r.file_id
|
603
|
-
await self.file.delete_file_blob(f_id)
|
604
654
|
await self.file.delete_file_record(url)
|
655
|
+
if r.external:
|
656
|
+
await self.file.delete_file_blob_external(f_id)
|
657
|
+
else:
|
658
|
+
await self.file.delete_file_blob(f_id)
|
605
659
|
return r
|
606
660
|
|
607
661
|
async def move_file(self, old_url: str, new_url: str):
|
@@ -611,17 +665,33 @@ class Database:
|
|
611
665
|
async with transaction(self):
|
612
666
|
await self.file.move_file(old_url, new_url)
|
613
667
|
|
668
|
+
async def __batch_delete_file_blobs(self, file_records: list[FileRecord], batch_size: int = 512):
|
669
|
+
# https://github.com/langchain-ai/langchain/issues/10321
|
670
|
+
internal_ids = []
|
671
|
+
external_ids = []
|
672
|
+
for r in file_records:
|
673
|
+
if r.external:
|
674
|
+
external_ids.append(r.file_id)
|
675
|
+
else:
|
676
|
+
internal_ids.append(r.file_id)
|
677
|
+
|
678
|
+
for i in range(0, len(internal_ids), batch_size):
|
679
|
+
await self.file.delete_file_blobs([r for r in internal_ids[i:i+batch_size]])
|
680
|
+
for i in range(0, len(external_ids)):
|
681
|
+
await self.file.delete_file_blob_external(external_ids[i])
|
682
|
+
|
683
|
+
|
614
684
|
async def delete_path(self, url: str):
|
615
685
|
validate_url(url, is_file=False)
|
616
686
|
|
617
687
|
async with transaction(self):
|
618
|
-
records = await self.file.
|
688
|
+
records = await self.file.get_path_file_records(url)
|
619
689
|
if not records:
|
620
690
|
return None
|
621
|
-
await self.
|
691
|
+
await self.__batch_delete_file_blobs(records)
|
622
692
|
await self.file.delete_path_records(url)
|
623
693
|
return records
|
624
|
-
|
694
|
+
|
625
695
|
async def delete_user(self, u: str | int):
|
626
696
|
user = await get_user(self, u)
|
627
697
|
if user is None:
|
@@ -629,11 +699,11 @@ class Database:
|
|
629
699
|
|
630
700
|
async with transaction(self):
|
631
701
|
records = await self.file.get_user_file_records(user.id)
|
632
|
-
await self.
|
702
|
+
await self.__batch_delete_file_blobs(records)
|
633
703
|
await self.file.delete_user_file_records(user.id)
|
634
704
|
await self.user.delete_user(user.username)
|
635
705
|
|
636
|
-
async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes]]:
|
706
|
+
async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes | AsyncIterable[bytes]]]:
|
637
707
|
if urls is None:
|
638
708
|
urls = [r.url for r in await self.file.list_path(top_url, flat=True)]
|
639
709
|
|
@@ -644,10 +714,13 @@ class Database:
|
|
644
714
|
if r is None:
|
645
715
|
continue
|
646
716
|
f_id = r.file_id
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
717
|
+
if r.external:
|
718
|
+
blob = self.file.get_file_blob_external(f_id)
|
719
|
+
else:
|
720
|
+
blob = await self.file.get_file_blob(f_id)
|
721
|
+
if blob is None:
|
722
|
+
self.logger.warning(f"Blob not found for {url}")
|
723
|
+
continue
|
651
724
|
yield r, blob
|
652
725
|
|
653
726
|
async def zip_path(self, top_url: str, urls: Optional[list[str]]) -> io.BytesIO:
|
@@ -658,7 +731,12 @@ class Database:
|
|
658
731
|
async for (r, blob) in self.iter_path(top_url, urls):
|
659
732
|
rel_path = r.url[len(top_url):]
|
660
733
|
rel_path = decode_uri_compnents(rel_path)
|
661
|
-
|
734
|
+
if r.external:
|
735
|
+
assert isinstance(blob, AsyncIterable)
|
736
|
+
zf.writestr(rel_path, b''.join([chunk async for chunk in blob]))
|
737
|
+
else:
|
738
|
+
assert isinstance(blob, bytes)
|
739
|
+
zf.writestr(rel_path, blob)
|
662
740
|
buffer.seek(0)
|
663
741
|
return buffer
|
664
742
|
|
@@ -1,7 +1,8 @@
|
|
1
|
-
from typing import Optional
|
1
|
+
from typing import Optional, Literal
|
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")
|
@@ -165,7 +179,7 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
|
|
165
179
|
async def put_file(
|
166
180
|
request: Request,
|
167
181
|
path: str,
|
168
|
-
|
182
|
+
conflict: Literal["overwrite", "skip", "abort"] = "abort",
|
169
183
|
permission: int = 0,
|
170
184
|
user: UserRecord = Depends(get_current_user)):
|
171
185
|
path = ensure_uri_compnents(path)
|
@@ -187,8 +201,12 @@ async def put_file(
|
|
187
201
|
exists_flag = False
|
188
202
|
file_record = await conn.file.get_file_record(path)
|
189
203
|
if file_record:
|
190
|
-
if
|
204
|
+
if conflict == "abort":
|
191
205
|
raise HTTPException(status_code=409, detail="File exists")
|
206
|
+
if conflict == "skip":
|
207
|
+
return Response(status_code=200, headers={
|
208
|
+
"Content-Type": "application/json",
|
209
|
+
}, content=json.dumps({"url": path}))
|
192
210
|
# remove the old file
|
193
211
|
exists_flag = True
|
194
212
|
await conn.delete_file(path)
|
@@ -198,20 +216,26 @@ async def put_file(
|
|
198
216
|
logger.debug(f"Content-Type: {content_type}")
|
199
217
|
if content_type == "application/json":
|
200
218
|
body = await request.json()
|
201
|
-
|
219
|
+
blobs = json.dumps(body).encode('utf-8')
|
202
220
|
elif content_type == "application/x-www-form-urlencoded":
|
203
221
|
# may not work...
|
204
222
|
body = await request.form()
|
205
223
|
file = body.get("file")
|
206
224
|
if isinstance(file, str) or file is None:
|
207
225
|
raise HTTPException(status_code=400, detail="Invalid form data, file required")
|
208
|
-
|
226
|
+
blobs = await file.read()
|
209
227
|
elif content_type == "application/octet-stream":
|
210
|
-
|
211
|
-
|
228
|
+
blobs = await request.body()
|
229
|
+
else:
|
230
|
+
blobs = await request.body()
|
231
|
+
if len(blobs) > LARGE_FILE_BYTES:
|
232
|
+
async def blob_reader():
|
233
|
+
chunk_size = 16 * 1024 * 1024 # 16MB
|
234
|
+
for b in range(0, len(blobs), chunk_size):
|
235
|
+
yield blobs[b:b+chunk_size]
|
236
|
+
await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
|
212
237
|
else:
|
213
|
-
|
214
|
-
await conn.save_file(user.id, path, body, permission = FileReadPermission(permission))
|
238
|
+
await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
|
215
239
|
|
216
240
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
|
217
241
|
if exists_flag:
|
@@ -289,19 +313,18 @@ async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
|
|
289
313
|
}
|
290
314
|
)
|
291
315
|
|
292
|
-
@router_api.get("/
|
316
|
+
@router_api.get("/meta")
|
293
317
|
@handle_exception
|
294
318
|
async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user)):
|
295
319
|
logger.info(f"GET meta({path}), user: {user.username}")
|
296
|
-
if path.endswith("/"):
|
297
|
-
raise HTTPException(status_code=400, detail="Invalid path")
|
298
320
|
path = ensure_uri_compnents(path)
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
321
|
+
get_fn = conn.file.get_file_record if not path.endswith("/") else conn.file.get_path_record
|
322
|
+
record = await get_fn(path)
|
323
|
+
if not record:
|
324
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
325
|
+
return record
|
303
326
|
|
304
|
-
@router_api.post("/
|
327
|
+
@router_api.post("/meta")
|
305
328
|
@handle_exception
|
306
329
|
async def update_file_meta(
|
307
330
|
path: str,
|
@@ -1,17 +1,18 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "lfss"
|
3
|
-
version = "0.
|
3
|
+
version = "0.5.1"
|
4
4
|
description = "Lightweight file storage service"
|
5
5
|
authors = ["li, mengxun <limengxun45@outlook.com>"]
|
6
6
|
readme = "Readme.md"
|
7
7
|
homepage = "https://github.com/MenxLi/lfss"
|
8
8
|
repository = "https://github.com/MenxLi/lfss"
|
9
|
-
include = ["Readme.md", "docs/*", "frontend/*"]
|
9
|
+
include = ["Readme.md", "docs/*", "frontend/*", "lfss/sql/*"]
|
10
10
|
|
11
11
|
[tool.poetry.dependencies]
|
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]
|
@@ -19,6 +20,7 @@ lfss-serve = "lfss.cli.serve:main"
|
|
19
20
|
lfss-user = "lfss.cli.user:main"
|
20
21
|
lfss-panel = "lfss.cli.panel:main"
|
21
22
|
lfss-cli = "lfss.cli.cli:main"
|
23
|
+
lfss-balance = "lfss.cli.balance:main"
|
22
24
|
|
23
25
|
[build-system]
|
24
26
|
requires = ["poetry-core>=1.0.0"]
|
lfss-0.4.1/lfss/src/config.py
DELETED
@@ -1,12 +0,0 @@
|
|
1
|
-
from pathlib import Path
|
2
|
-
import os
|
3
|
-
|
4
|
-
__default_dir = '.storage_data'
|
5
|
-
|
6
|
-
DATA_HOME = Path(os.environ.get('LFSS_DATA', __default_dir))
|
7
|
-
if not DATA_HOME.exists():
|
8
|
-
DATA_HOME.mkdir()
|
9
|
-
print(f"[init] Created data home at {DATA_HOME}")
|
10
|
-
|
11
|
-
MAX_FILE_BYTES = 256 * 1024 * 1024 # 256MB
|
12
|
-
MAX_BUNDLE_BYTES = 256 * 1024 * 1024 # 256MB
|
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
|