lfss 0.5.0__tar.gz → 0.5.2__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.5.0 → lfss-0.5.2}/PKG-INFO +1 -1
- {lfss-0.5.0 → lfss-0.5.2}/frontend/api.js +3 -3
- {lfss-0.5.0 → lfss-0.5.2}/frontend/popup.js +13 -1
- {lfss-0.5.0 → lfss-0.5.2}/frontend/scripts.js +39 -7
- {lfss-0.5.0 → lfss-0.5.2}/frontend/styles.css +1 -1
- lfss-0.5.2/lfss/cli/balance.py +124 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/cli/cli.py +4 -4
- {lfss-0.5.0 → lfss-0.5.2}/lfss/client/api.py +29 -10
- lfss-0.5.2/lfss/sql/init.sql +38 -0
- lfss-0.5.2/lfss/sql/pragma.sql +5 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/src/config.py +2 -1
- {lfss-0.5.0 → lfss-0.5.2}/lfss/src/database.py +39 -53
- {lfss-0.5.0 → lfss-0.5.2}/lfss/src/server.py +60 -23
- {lfss-0.5.0 → lfss-0.5.2}/pyproject.toml +3 -2
- {lfss-0.5.0 → lfss-0.5.2}/Readme.md +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/docs/Known_issues.md +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/docs/Permission.md +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/frontend/index.html +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/frontend/popup.css +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/frontend/utils.js +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/cli/panel.py +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/cli/serve.py +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/cli/user.py +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/client/__init__.py +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/src/__init__.py +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/src/error.py +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/src/log.py +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/src/stat.py +0 -0
- {lfss-0.5.0 → lfss-0.5.2}/lfss/src/utils.py +0 -0
@@ -57,13 +57,13 @@ export default class Connector {
|
|
57
57
|
* @returns {Promise<string>} - the promise of the request, the url of the file
|
58
58
|
*/
|
59
59
|
async put(path, file, {
|
60
|
-
|
60
|
+
conflict = 'abort',
|
61
61
|
permission = 0
|
62
62
|
} = {}){
|
63
63
|
if (path.startsWith('/')){ path = path.slice(1); }
|
64
64
|
const fileBytes = await file.arrayBuffer();
|
65
65
|
const dst = new URL(this.config.endpoint + '/' + path);
|
66
|
-
dst.searchParams.append('
|
66
|
+
dst.searchParams.append('conflict', conflict);
|
67
67
|
dst.searchParams.append('permission', permission);
|
68
68
|
const res = await fetch(dst.toString(), {
|
69
69
|
method: 'PUT',
|
@@ -191,7 +191,7 @@ export default class Connector {
|
|
191
191
|
* @param {string} path - file path(url)
|
192
192
|
* @param {string} newPath - new file path(url)
|
193
193
|
*/
|
194
|
-
async
|
194
|
+
async move(path, newPath){
|
195
195
|
if (path.startsWith('/')){ path = path.slice(1); }
|
196
196
|
if (newPath.startsWith('/')){ newPath = newPath.slice(1); }
|
197
197
|
const dst = new URL(this.config.endpoint + '/_api/meta');
|
@@ -40,7 +40,7 @@ export function createFloatingWindow(innerHTML = '', {
|
|
40
40
|
return [floatingWindow, closeWindow];
|
41
41
|
}
|
42
42
|
|
43
|
-
/* select can be "last-filename" */
|
43
|
+
/* select can be "last-filename" or "last-pathname" */
|
44
44
|
export function showFloatingWindowLineInput(onSubmit = (v) => {}, {
|
45
45
|
text = "",
|
46
46
|
placeholder = "Enter text",
|
@@ -72,6 +72,7 @@ export function showFloatingWindowLineInput(onSubmit = (v) => {}, {
|
|
72
72
|
};
|
73
73
|
|
74
74
|
if (select === "last-filename") {
|
75
|
+
// select the last filename, e.g. "file" in "/path/to/file.txt"
|
75
76
|
const inputVal = input.value;
|
76
77
|
let lastSlash = inputVal.lastIndexOf("/");
|
77
78
|
if (lastSlash === -1) {
|
@@ -84,6 +85,17 @@ export function showFloatingWindowLineInput(onSubmit = (v) => {}, {
|
|
84
85
|
}
|
85
86
|
input.setSelectionRange(lastSlash + 1, lastSlash + lastDot + 1);
|
86
87
|
}
|
88
|
+
else if (select === "last-pathname") {
|
89
|
+
// select the last pathname, e.g. "to" in "/path/to/<filename>"
|
90
|
+
const lastSlash = input.value.lastIndexOf("/");
|
91
|
+
const secondLastSlash = input.value.lastIndexOf("/", input.value.lastIndexOf("/") - 1);
|
92
|
+
if (secondLastSlash !== -1) {
|
93
|
+
input.setSelectionRange(secondLastSlash + 1, lastSlash);
|
94
|
+
}
|
95
|
+
else {
|
96
|
+
input.setSelectionRange(0, lastSlash);
|
97
|
+
}
|
98
|
+
}
|
87
99
|
|
88
100
|
return [floatingWindow, closeWindow];
|
89
101
|
}
|
@@ -5,6 +5,9 @@ import { formatSize, decodePathURI, ensurePathURI, copyToClipboard, getRandomStr
|
|
5
5
|
|
6
6
|
const conn = new Connector();
|
7
7
|
let userRecord = null;
|
8
|
+
const ensureSlashEnd = (path) => {
|
9
|
+
return path.endsWith('/') ? path : path + '/';
|
10
|
+
}
|
8
11
|
|
9
12
|
const endpointInput = document.querySelector('input#endpoint');
|
10
13
|
const tokenInput = document.querySelector('input#token');
|
@@ -128,11 +131,13 @@ uploadButton.addEventListener('click', () => {
|
|
128
131
|
throw new Error('File name cannot end with /');
|
129
132
|
}
|
130
133
|
path = path + fileName;
|
134
|
+
showPopup('Uploading...', {level: 'info', timeout: 3000});
|
131
135
|
conn.put(path, file)
|
132
136
|
.then(() => {
|
133
137
|
refreshFileList();
|
134
138
|
uploadFileNameInput.value = '';
|
135
139
|
onFileNameInpuChange();
|
140
|
+
showPopup('Upload success.', {level: 'success', timeout: 3000});
|
136
141
|
},
|
137
142
|
(err) => {
|
138
143
|
showPopup('Failed to upload file: ' + err, {level: 'error', timeout: 5000});
|
@@ -176,7 +181,7 @@ Are you sure you want to proceed?
|
|
176
181
|
async function uploadFile(...args){
|
177
182
|
const [file, path] = args;
|
178
183
|
try{
|
179
|
-
await conn.put(path, file, {
|
184
|
+
await conn.put(path, file, {conflict: 'overwrite'});
|
180
185
|
}
|
181
186
|
catch (err){
|
182
187
|
showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
|
@@ -191,9 +196,14 @@ Are you sure you want to proceed?
|
|
191
196
|
const path = dstPath + file.name;
|
192
197
|
promises.push(uploadFile(file, path));
|
193
198
|
}
|
199
|
+
showPopup('Uploading multiple files...', {level: 'info', timeout: 3000});
|
194
200
|
Promise.all(promises).then(
|
195
201
|
() => {
|
202
|
+
showPopup('Upload success.', {level: 'success', timeout: 3000});
|
196
203
|
refreshFileList();
|
204
|
+
},
|
205
|
+
(err) => {
|
206
|
+
showPopup('Failed to upload some files: ' + err, {level: 'error', timeout: 5000});
|
197
207
|
}
|
198
208
|
);
|
199
209
|
}
|
@@ -260,15 +270,16 @@ function refreshFileList(){
|
|
260
270
|
tr.appendChild(accessTd);
|
261
271
|
}
|
262
272
|
{
|
273
|
+
const dirurl = ensureSlashEnd(dir.url);
|
263
274
|
const actTd = document.createElement('td');
|
264
275
|
const actContainer = document.createElement('div');
|
265
276
|
actContainer.classList.add('action-container');
|
266
277
|
|
267
278
|
const showMetaButton = document.createElement('a');
|
268
|
-
showMetaButton.textContent = '
|
279
|
+
showMetaButton.textContent = 'Reveal';
|
269
280
|
showMetaButton.style.cursor = 'pointer';
|
270
281
|
showMetaButton.addEventListener('click', () => {
|
271
|
-
const dirUrlEncap =
|
282
|
+
const dirUrlEncap = dirurl;
|
272
283
|
conn.getMetadata(dirUrlEncap).then(
|
273
284
|
(meta) => {
|
274
285
|
sizeTd.textContent = formatSize(meta.size);
|
@@ -280,6 +291,30 @@ function refreshFileList(){
|
|
280
291
|
});
|
281
292
|
actContainer.appendChild(showMetaButton);
|
282
293
|
|
294
|
+
const moveButton = document.createElement('a');
|
295
|
+
moveButton.textContent = 'Move';
|
296
|
+
moveButton.style.cursor = 'pointer';
|
297
|
+
moveButton.addEventListener('click', () => {
|
298
|
+
showFloatingWindowLineInput((dstPath) => {
|
299
|
+
dstPath = encodePathURI(dstPath);
|
300
|
+
console.log("Moving", dirurl, "to", dstPath);
|
301
|
+
conn.move(dirurl, dstPath)
|
302
|
+
.then(() => {
|
303
|
+
refreshFileList();
|
304
|
+
},
|
305
|
+
(err) => {
|
306
|
+
showPopup('Failed to move path: ' + err, {level: 'error'});
|
307
|
+
}
|
308
|
+
);
|
309
|
+
}, {
|
310
|
+
text: 'Enter the destination path: ',
|
311
|
+
placeholder: 'Destination path',
|
312
|
+
value: decodePathURI(dirurl),
|
313
|
+
select: "last-pathname"
|
314
|
+
});
|
315
|
+
});
|
316
|
+
actContainer.appendChild(moveButton);
|
317
|
+
|
283
318
|
const downloadButton = document.createElement('a');
|
284
319
|
downloadButton.textContent = 'Download';
|
285
320
|
downloadButton.href = conn.config.endpoint + '/_api/bundle?' +
|
@@ -402,10 +437,7 @@ function refreshFileList(){
|
|
402
437
|
moveButton.addEventListener('click', () => {
|
403
438
|
showFloatingWindowLineInput((dstPath) => {
|
404
439
|
dstPath = encodePathURI(dstPath);
|
405
|
-
|
406
|
-
dstPath = dstPath.slice(0, -1);
|
407
|
-
}
|
408
|
-
conn.moveFile(file.url, dstPath)
|
440
|
+
conn.move(file.url, dstPath)
|
409
441
|
.then(() => {
|
410
442
|
refreshFileList();
|
411
443
|
},
|
@@ -0,0 +1,124 @@
|
|
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(batch_size: int = 10000):
|
72
|
+
|
73
|
+
tasks = []
|
74
|
+
start_time = time.time()
|
75
|
+
|
76
|
+
e_cout = 0
|
77
|
+
batch_count = 0
|
78
|
+
while True:
|
79
|
+
async with aiosqlite.connect(db_file) as conn:
|
80
|
+
exceeded_rows = list(await (await conn.execute(
|
81
|
+
"SELECT file_id FROM fmeta WHERE file_size > ? AND external = 0 LIMIT ? OFFSET ?",
|
82
|
+
(LARGE_FILE_BYTES, batch_size, batch_size * batch_count)
|
83
|
+
)).fetchall())
|
84
|
+
if not exceeded_rows:
|
85
|
+
break
|
86
|
+
e_cout += len(exceeded_rows)
|
87
|
+
for i in range(0, len(exceeded_rows)):
|
88
|
+
row = exceeded_rows[i]
|
89
|
+
f_id = row[0]
|
90
|
+
tasks.append(move_to_external(f_id, flag=f"[b{batch_count+1}-e{i+1}/{len(exceeded_rows)}] "))
|
91
|
+
await asyncio.gather(*tasks)
|
92
|
+
|
93
|
+
i_count = 0
|
94
|
+
batch_count = 0
|
95
|
+
while True:
|
96
|
+
async with aiosqlite.connect(db_file) as conn:
|
97
|
+
under_rows = list(await (await conn.execute(
|
98
|
+
"SELECT file_id, file_size, external FROM fmeta WHERE file_size <= ? AND external = 1 LIMIT ? OFFSET ?",
|
99
|
+
(LARGE_FILE_BYTES, batch_size, batch_size * batch_count)
|
100
|
+
)).fetchall())
|
101
|
+
if not under_rows:
|
102
|
+
break
|
103
|
+
i_count += len(under_rows)
|
104
|
+
for i in range(0, len(under_rows)):
|
105
|
+
row = under_rows[i]
|
106
|
+
f_id = row[0]
|
107
|
+
tasks.append(move_to_internal(f_id, flag=f"[b{batch_count+1}-i{i+1}/{len(under_rows)}] "))
|
108
|
+
await asyncio.gather(*tasks)
|
109
|
+
|
110
|
+
end_time = time.time()
|
111
|
+
print(f"Balancing complete, took {end_time - start_time:.2f} seconds. "
|
112
|
+
f"{e_cout} files moved to external storage, {i_count} files moved to internal storage.")
|
113
|
+
|
114
|
+
def main():
|
115
|
+
global sem
|
116
|
+
parser = argparse.ArgumentParser(description="Balance the storage by ensuring that large file thresholds are met.")
|
117
|
+
parser.add_argument("-j", "--jobs", type=int, default=2, help="Number of concurrent jobs")
|
118
|
+
parser.add_argument("-b", "--batch-size", type=int, default=10000, help="Batch size for processing files")
|
119
|
+
args = parser.parse_args()
|
120
|
+
sem = Semaphore(args.jobs)
|
121
|
+
asyncio.run(_main(args.batch_size))
|
122
|
+
|
123
|
+
if __name__ == '__main__':
|
124
|
+
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
|
|
@@ -32,7 +32,7 @@ def main():
|
|
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
38
|
if failed_upload:
|
@@ -44,7 +44,7 @@ def main():
|
|
44
44
|
connector.put(
|
45
45
|
args.dst,
|
46
46
|
f.read(),
|
47
|
-
|
47
|
+
conflict=args.conflict,
|
48
48
|
permission=args.permission
|
49
49
|
)
|
50
50
|
else:
|
@@ -34,33 +34,52 @@ 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
|
-
if path.startswith('/'):
|
40
|
-
path = path[1:]
|
41
39
|
response = self._fetch('PUT', path, search_params={
|
42
40
|
'permission': int(permission),
|
43
|
-
'
|
41
|
+
'conflict': conflict
|
44
42
|
})(
|
45
43
|
data=file_data,
|
46
44
|
headers={'Content-Type': 'application/octet-stream'}
|
47
45
|
)
|
48
46
|
return response.json()
|
49
47
|
|
50
|
-
def
|
51
|
-
"""
|
48
|
+
def put_json(self, path: str, data: dict, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip'] = 'abort'):
|
49
|
+
"""Uploads a JSON file to the specified path."""
|
50
|
+
assert path.endswith('.json'), "Path must end with .json"
|
51
|
+
response = self._fetch('PUT', path, search_params={
|
52
|
+
'permission': int(permission),
|
53
|
+
'conflict': conflict
|
54
|
+
})(
|
55
|
+
json=data,
|
56
|
+
headers={'Content-Type': 'application/json'}
|
57
|
+
)
|
58
|
+
return response.json()
|
59
|
+
|
60
|
+
def _get(self, path: str) -> Optional[requests.Response]:
|
52
61
|
try:
|
53
62
|
response = self._fetch('GET', path)()
|
54
63
|
except requests.exceptions.HTTPError as e:
|
55
64
|
if e.response.status_code == 404:
|
56
65
|
return None
|
57
66
|
raise e
|
67
|
+
return response
|
68
|
+
|
69
|
+
def get(self, path: str) -> Optional[bytes]:
|
70
|
+
"""Downloads a file from the specified path."""
|
71
|
+
response = self._get(path)
|
72
|
+
if response is None: return None
|
58
73
|
return response.content
|
74
|
+
|
75
|
+
def get_json(self, path: str) -> Optional[dict]:
|
76
|
+
response = self._get(path)
|
77
|
+
if response is None: return None
|
78
|
+
assert response.headers['Content-Type'] == 'application/json'
|
79
|
+
return response.json()
|
59
80
|
|
60
81
|
def delete(self, path: str):
|
61
82
|
"""Deletes the file at the specified path."""
|
62
|
-
if path.startswith('/'):
|
63
|
-
path = path[1:]
|
64
83
|
self._fetch('DELETE', path)()
|
65
84
|
|
66
85
|
def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
|
@@ -87,8 +106,8 @@ class Connector:
|
|
87
106
|
headers={'Content-Type': 'application/www-form-urlencoded'}
|
88
107
|
)
|
89
108
|
|
90
|
-
def
|
91
|
-
"""
|
109
|
+
def move(self, path: str, new_path: str):
|
110
|
+
"""Move file or directory to a new path."""
|
92
111
|
self._fetch('POST', '_api/meta', {'path': path, 'new_path': new_path})(
|
93
112
|
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
94
113
|
)
|
@@ -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);
|
@@ -10,6 +10,7 @@ if not DATA_HOME.exists():
|
|
10
10
|
LARGE_BLOB_DIR = DATA_HOME / 'large_blobs'
|
11
11
|
LARGE_BLOB_DIR.mkdir(exist_ok=True)
|
12
12
|
|
13
|
-
|
13
|
+
# https://sqlite.org/fasterthanfs.html
|
14
|
+
LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
|
14
15
|
MAX_FILE_BYTES = 1024 * 1024 * 1024 # 1GB
|
15
16
|
MAX_BUNDLE_BYTES = 1024 * 1024 * 1024 # 1GB
|
@@ -3,6 +3,7 @@ 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
|
@@ -23,6 +24,15 @@ _g_conn: Optional[aiosqlite.Connection] = None
|
|
23
24
|
def hash_credential(username, password):
|
24
25
|
return hashlib.sha256((username + password).encode()).hexdigest()
|
25
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
|
+
|
26
36
|
_atomic_lock = Lock()
|
27
37
|
def atomic(func):
|
28
38
|
""" Ensure non-reentrancy """
|
@@ -48,7 +58,8 @@ class DBConnBase(ABC):
|
|
48
58
|
global _g_conn
|
49
59
|
if _g_conn is None:
|
50
60
|
_g_conn = await aiosqlite.connect(DATA_HOME / 'lfss.db')
|
51
|
-
await _g_conn.
|
61
|
+
await execute_sql(_g_conn, 'pragma.sql')
|
62
|
+
await execute_sql(_g_conn, 'init.sql')
|
52
63
|
|
53
64
|
async def commit(self):
|
54
65
|
await self.conn.commit()
|
@@ -82,26 +93,6 @@ class UserConn(DBConnBase):
|
|
82
93
|
|
83
94
|
async def init(self):
|
84
95
|
await super().init()
|
85
|
-
# default to 1GB (1024x1024x1024 bytes)
|
86
|
-
await self.conn.execute('''
|
87
|
-
CREATE TABLE IF NOT EXISTS user (
|
88
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
89
|
-
username VARCHAR(255) UNIQUE NOT NULL,
|
90
|
-
credential VARCHAR(255) NOT NULL,
|
91
|
-
is_admin BOOLEAN DEFAULT FALSE,
|
92
|
-
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
93
|
-
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
94
|
-
max_storage INTEGER DEFAULT 1073741824,
|
95
|
-
permission INTEGER DEFAULT 0
|
96
|
-
)
|
97
|
-
''')
|
98
|
-
await self.conn.execute('''
|
99
|
-
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username)
|
100
|
-
''')
|
101
|
-
await self.conn.execute('''
|
102
|
-
CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential)
|
103
|
-
''')
|
104
|
-
|
105
96
|
return self
|
106
97
|
|
107
98
|
async def get_user(self, username: str) -> Optional[UserRecord]:
|
@@ -222,37 +213,6 @@ class FileConn(DBConnBase):
|
|
222
213
|
|
223
214
|
async def init(self):
|
224
215
|
await super().init()
|
225
|
-
await self.conn.execute('''
|
226
|
-
CREATE TABLE IF NOT EXISTS fmeta (
|
227
|
-
url VARCHAR(512) PRIMARY KEY,
|
228
|
-
owner_id INTEGER NOT NULL,
|
229
|
-
file_id VARCHAR(256) NOT NULL,
|
230
|
-
file_size INTEGER,
|
231
|
-
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
232
|
-
access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
233
|
-
permission INTEGER DEFAULT 0,
|
234
|
-
external BOOLEAN DEFAULT FALSE,
|
235
|
-
FOREIGN KEY(owner_id) REFERENCES user(id)
|
236
|
-
)
|
237
|
-
''')
|
238
|
-
await self.conn.execute('''
|
239
|
-
CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url)
|
240
|
-
''')
|
241
|
-
|
242
|
-
await self.conn.execute('''
|
243
|
-
CREATE TABLE IF NOT EXISTS fdata (
|
244
|
-
file_id VARCHAR(256) PRIMARY KEY,
|
245
|
-
data BLOB
|
246
|
-
)
|
247
|
-
''')
|
248
|
-
|
249
|
-
# user file size table
|
250
|
-
await self.conn.execute('''
|
251
|
-
CREATE TABLE IF NOT EXISTS usize (
|
252
|
-
user_id INTEGER PRIMARY KEY,
|
253
|
-
size INTEGER DEFAULT 0
|
254
|
-
)
|
255
|
-
''')
|
256
216
|
# backward compatibility, since 0.2.1
|
257
217
|
async with self.conn.execute("SELECT * FROM user") as cursor:
|
258
218
|
res = await cursor.fetchall()
|
@@ -371,7 +331,7 @@ class FileConn(DBConnBase):
|
|
371
331
|
dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
|
372
332
|
return PathContents(dirs, files)
|
373
333
|
|
374
|
-
async def get_path_record(self, url: str) ->
|
334
|
+
async def get_path_record(self, url: str) -> DirectoryRecord:
|
375
335
|
assert url.endswith('/'), "Path must end with /"
|
376
336
|
async with self.conn.execute("""
|
377
337
|
SELECT MIN(create_time) as create_time,
|
@@ -461,6 +421,25 @@ class FileConn(DBConnBase):
|
|
461
421
|
async with self.conn.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url)):
|
462
422
|
self.logger.info(f"Moved file {old_url} to {new_url}")
|
463
423
|
|
424
|
+
@atomic
|
425
|
+
async def move_path(self, old_url: str, new_url: str, conflict_handler: Literal['skip', 'overwrite'] = 'overwrite', user_id: Optional[int] = None):
|
426
|
+
assert old_url.endswith('/'), "Old path must end with /"
|
427
|
+
assert new_url.endswith('/'), "New path must end with /"
|
428
|
+
if user_id is None:
|
429
|
+
async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (old_url + '%', )) as cursor:
|
430
|
+
res = await cursor.fetchall()
|
431
|
+
else:
|
432
|
+
async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ? AND owner_id = ?", (old_url + '%', user_id)) as cursor:
|
433
|
+
res = await cursor.fetchall()
|
434
|
+
for r in res:
|
435
|
+
new_r = new_url + r[0][len(old_url):]
|
436
|
+
if conflict_handler == 'overwrite':
|
437
|
+
await self.conn.execute("DELETE FROM fmeta WHERE url = ?", (new_r, ))
|
438
|
+
elif conflict_handler == 'skip':
|
439
|
+
if (await self.conn.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))) is not None:
|
440
|
+
continue
|
441
|
+
await self.conn.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_r, r[0]))
|
442
|
+
|
464
443
|
async def log_access(self, url: str):
|
465
444
|
await self.conn.execute("UPDATE fmeta SET access_time = CURRENT_TIMESTAMP WHERE url = ?", (url, ))
|
466
445
|
|
@@ -704,6 +683,13 @@ class Database:
|
|
704
683
|
|
705
684
|
async with transaction(self):
|
706
685
|
await self.file.move_file(old_url, new_url)
|
686
|
+
|
687
|
+
async def move_path(self, old_url: str, new_url: str, user_id: Optional[int] = None):
|
688
|
+
validate_url(old_url, is_file=False)
|
689
|
+
validate_url(new_url, is_file=False)
|
690
|
+
|
691
|
+
async with transaction(self):
|
692
|
+
await self.file.move_path(old_url, new_url, 'overwrite', user_id)
|
707
693
|
|
708
694
|
async def __batch_delete_file_blobs(self, file_records: list[FileRecord], batch_size: int = 512):
|
709
695
|
# https://github.com/langchain-ai/langchain/issues/10321
|
@@ -1,4 +1,4 @@
|
|
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
|
@@ -179,7 +179,7 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
|
|
179
179
|
async def put_file(
|
180
180
|
request: Request,
|
181
181
|
path: str,
|
182
|
-
|
182
|
+
conflict: Literal["overwrite", "skip", "abort"] = "abort",
|
183
183
|
permission: int = 0,
|
184
184
|
user: UserRecord = Depends(get_current_user)):
|
185
185
|
path = ensure_uri_compnents(path)
|
@@ -201,8 +201,12 @@ async def put_file(
|
|
201
201
|
exists_flag = False
|
202
202
|
file_record = await conn.file.get_file_record(path)
|
203
203
|
if file_record:
|
204
|
-
if
|
204
|
+
if conflict == "abort":
|
205
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}))
|
206
210
|
# remove the old file
|
207
211
|
exists_flag = True
|
208
212
|
await conn.delete_file(path)
|
@@ -226,8 +230,9 @@ async def put_file(
|
|
226
230
|
blobs = await request.body()
|
227
231
|
if len(blobs) > LARGE_FILE_BYTES:
|
228
232
|
async def blob_reader():
|
229
|
-
|
230
|
-
|
233
|
+
chunk_size = 16 * 1024 * 1024 # 16MB
|
234
|
+
for b in range(0, len(blobs), chunk_size):
|
235
|
+
yield blobs[b:b+chunk_size]
|
231
236
|
await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
|
232
237
|
else:
|
233
238
|
await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
|
@@ -258,6 +263,7 @@ async def delete_file(path: str, user: UserRecord = Depends(get_current_user)):
|
|
258
263
|
else:
|
259
264
|
res = await conn.delete_file(path)
|
260
265
|
|
266
|
+
await conn.user.set_active(user.username)
|
261
267
|
if res:
|
262
268
|
return Response(status_code=200, content="Deleted")
|
263
269
|
else:
|
@@ -330,26 +336,57 @@ async def update_file_meta(
|
|
330
336
|
if user.id == 0:
|
331
337
|
raise HTTPException(status_code=401, detail="Permission denied")
|
332
338
|
path = ensure_uri_compnents(path)
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
if not (
|
339
|
-
|
340
|
-
|
339
|
+
if path.startswith("/"):
|
340
|
+
path = path[1:]
|
341
|
+
await conn.user.set_active(user.username)
|
342
|
+
|
343
|
+
# file
|
344
|
+
if not path.endswith("/"):
|
345
|
+
file_record = await conn.file.get_file_record(path)
|
346
|
+
if not file_record:
|
347
|
+
logger.debug(f"Reject update meta request from {user.username} to {path}")
|
348
|
+
raise HTTPException(status_code=404, detail="File not found")
|
349
|
+
|
350
|
+
if not (user.is_admin or user.id == file_record.owner_id):
|
351
|
+
logger.debug(f"Reject update meta request from {user.username} to {path}")
|
352
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
353
|
+
|
354
|
+
if perm is not None:
|
355
|
+
logger.info(f"Update permission of {path} to {perm}")
|
356
|
+
await conn.file.set_file_record(
|
357
|
+
url = file_record.url,
|
358
|
+
permission = FileReadPermission(perm)
|
359
|
+
)
|
341
360
|
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
permission = FileReadPermission(perm)
|
347
|
-
)
|
361
|
+
if new_path is not None:
|
362
|
+
new_path = ensure_uri_compnents(new_path)
|
363
|
+
logger.info(f"Update path of {path} to {new_path}")
|
364
|
+
await conn.move_file(path, new_path)
|
348
365
|
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
366
|
+
# directory
|
367
|
+
else:
|
368
|
+
assert perm is None, "Permission is not supported for directory"
|
369
|
+
if new_path is not None:
|
370
|
+
new_path = ensure_uri_compnents(new_path)
|
371
|
+
logger.info(f"Update path of {path} to {new_path}")
|
372
|
+
assert new_path.endswith("/"), "New path must end with /"
|
373
|
+
if new_path.startswith("/"):
|
374
|
+
new_path = new_path[1:]
|
375
|
+
|
376
|
+
# check if new path is under the user's directory
|
377
|
+
first_component = new_path.split("/")[0]
|
378
|
+
if not (first_component == user.username or user.is_admin):
|
379
|
+
raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
|
380
|
+
elif user.is_admin:
|
381
|
+
_is_user = await conn.user.get_user(first_component)
|
382
|
+
if not _is_user:
|
383
|
+
raise HTTPException(status_code=404, detail="User not found, path must start with username")
|
384
|
+
|
385
|
+
# check if old path is under the user's directory (non-admin)
|
386
|
+
if not path.startswith(f"{user.username}/") and not user.is_admin:
|
387
|
+
raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
|
388
|
+
# currently only move own file, with overwrite
|
389
|
+
await conn.move_path(path, new_path, user_id = user.id)
|
353
390
|
|
354
391
|
return Response(status_code=200, content="OK")
|
355
392
|
|
@@ -1,12 +1,12 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "lfss"
|
3
|
-
version = "0.5.
|
3
|
+
version = "0.5.2"
|
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"
|
@@ -20,6 +20,7 @@ lfss-serve = "lfss.cli.serve:main"
|
|
20
20
|
lfss-user = "lfss.cli.user:main"
|
21
21
|
lfss-panel = "lfss.cli.panel:main"
|
22
22
|
lfss-cli = "lfss.cli.cli:main"
|
23
|
+
lfss-balance = "lfss.cli.balance:main"
|
23
24
|
|
24
25
|
[build-system]
|
25
26
|
requires = ["poetry-core>=1.0.0"]
|
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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|