lfss 0.4.0__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.0 → lfss-0.5.0}/PKG-INFO +2 -1
- {lfss-0.4.0 → lfss-0.5.0}/frontend/api.js +15 -7
- {lfss-0.4.0 → lfss-0.5.0}/frontend/scripts.js +29 -11
- {lfss-0.4.0 → lfss-0.5.0}/frontend/styles.css +1 -0
- {lfss-0.4.0 → lfss-0.5.0}/frontend/utils.js +6 -0
- lfss-0.5.0/lfss/cli/cli.py +52 -0
- {lfss-0.4.0 → lfss-0.5.0}/lfss/cli/user.py +2 -0
- lfss-0.5.0/lfss/client/__init__.py +60 -0
- {lfss-0.4.0 → lfss-0.5.0}/lfss/client/api.py +16 -8
- {lfss-0.4.0 → lfss-0.5.0}/lfss/src/config.py +5 -2
- {lfss-0.4.0 → lfss-0.5.0}/lfss/src/database.py +155 -37
- {lfss-0.4.0 → lfss-0.5.0}/lfss/src/error.py +2 -0
- {lfss-0.4.0 → lfss-0.5.0}/lfss/src/server.py +55 -30
- {lfss-0.4.0 → lfss-0.5.0}/lfss/src/stat.py +1 -1
- {lfss-0.4.0 → lfss-0.5.0}/pyproject.toml +3 -1
- lfss-0.4.0/lfss/src/__init__.py +0 -0
- {lfss-0.4.0 → lfss-0.5.0}/Readme.md +0 -0
- {lfss-0.4.0 → lfss-0.5.0}/docs/Known_issues.md +0 -0
- {lfss-0.4.0 → lfss-0.5.0}/docs/Permission.md +0 -0
- {lfss-0.4.0 → lfss-0.5.0}/frontend/index.html +0 -0
- {lfss-0.4.0 → lfss-0.5.0}/frontend/popup.css +0 -0
- {lfss-0.4.0 → lfss-0.5.0}/frontend/popup.js +0 -0
- {lfss-0.4.0 → lfss-0.5.0}/lfss/cli/panel.py +0 -0
- {lfss-0.4.0 → lfss-0.5.0}/lfss/cli/serve.py +0 -0
- {lfss-0.4.0/lfss/client → lfss-0.5.0/lfss/src}/__init__.py +0 -0
- {lfss-0.4.0 → lfss-0.5.0}/lfss/src/log.py +0 -0
- {lfss-0.4.0 → 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
|
@@ -54,10 +56,16 @@ export default class Connector {
|
|
54
56
|
* @param {File} file - the file to upload
|
55
57
|
* @returns {Promise<string>} - the promise of the request, the url of the file
|
56
58
|
*/
|
57
|
-
async put(path, file
|
59
|
+
async put(path, file, {
|
60
|
+
overwrite = false,
|
61
|
+
permission = 0
|
62
|
+
} = {}){
|
58
63
|
if (path.startsWith('/')){ path = path.slice(1); }
|
59
64
|
const fileBytes = await file.arrayBuffer();
|
60
|
-
const
|
65
|
+
const dst = new URL(this.config.endpoint + '/' + path);
|
66
|
+
dst.searchParams.append('overwrite', overwrite);
|
67
|
+
dst.searchParams.append('permission', permission);
|
68
|
+
const res = await fetch(dst.toString(), {
|
61
69
|
method: 'PUT',
|
62
70
|
headers: {
|
63
71
|
'Authorization': 'Bearer ' + this.config.token,
|
@@ -106,12 +114,12 @@ export default class Connector {
|
|
106
114
|
}
|
107
115
|
|
108
116
|
/**
|
109
|
-
* @param {string} path - the path to the file
|
110
|
-
* @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
|
111
119
|
*/
|
112
120
|
async getMetadata(path){
|
113
121
|
if (path.startsWith('/')){ path = path.slice(1); }
|
114
|
-
const res = await fetch(this.config.endpoint + '/_api/
|
122
|
+
const res = await fetch(this.config.endpoint + '/_api/meta?path=' + path, {
|
115
123
|
method: 'GET',
|
116
124
|
headers: {
|
117
125
|
'Authorization': 'Bearer ' + this.config.token
|
@@ -165,7 +173,7 @@ export default class Connector {
|
|
165
173
|
*/
|
166
174
|
async setFilePermission(path, permission){
|
167
175
|
if (path.startsWith('/')){ path = path.slice(1); }
|
168
|
-
const dst = new URL(this.config.endpoint + '/_api/
|
176
|
+
const dst = new URL(this.config.endpoint + '/_api/meta');
|
169
177
|
dst.searchParams.append('path', path);
|
170
178
|
dst.searchParams.append('perm', permission);
|
171
179
|
const res = await fetch(dst.toString(), {
|
@@ -186,7 +194,7 @@ export default class Connector {
|
|
186
194
|
async moveFile(path, newPath){
|
187
195
|
if (path.startsWith('/')){ path = path.slice(1); }
|
188
196
|
if (newPath.startsWith('/')){ newPath = newPath.slice(1); }
|
189
|
-
const dst = new URL(this.config.endpoint + '/_api/
|
197
|
+
const dst = new URL(this.config.endpoint + '/_api/meta');
|
190
198
|
dst.searchParams.append('path', path);
|
191
199
|
dst.searchParams.append('new_path', newPath);
|
192
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, {overwrite: true});
|
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,52 @@
|
|
1
|
+
from lfss.client import Connector, upload_directory
|
2
|
+
from lfss.src.database import FileReadPermission
|
3
|
+
from pathlib import Path
|
4
|
+
import argparse
|
5
|
+
|
6
|
+
def parse_arguments():
|
7
|
+
parser = argparse.ArgumentParser(description="Command line interface, please set LFSS_ENDPOINT and LFSS_TOKEN environment variables.")
|
8
|
+
|
9
|
+
sp = parser.add_subparsers(dest="command", required=True)
|
10
|
+
|
11
|
+
# upload
|
12
|
+
sp_upload = sp.add_parser("upload", help="Upload files")
|
13
|
+
sp_upload.add_argument("src", help="Source file or directory", type=str)
|
14
|
+
sp_upload.add_argument("dst", help="Destination path", type=str)
|
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 retries, only works with directory upload")
|
17
|
+
sp_upload.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
|
18
|
+
sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
|
19
|
+
sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
|
20
|
+
|
21
|
+
return parser.parse_args()
|
22
|
+
|
23
|
+
def main():
|
24
|
+
args = parse_arguments()
|
25
|
+
connector = Connector()
|
26
|
+
if args.command == "upload":
|
27
|
+
src_path = Path(args.src)
|
28
|
+
if src_path.is_dir():
|
29
|
+
failed_upload = upload_directory(
|
30
|
+
connector, args.src, args.dst,
|
31
|
+
verbose=True,
|
32
|
+
n_concurrent=args.jobs,
|
33
|
+
n_reties=args.retries,
|
34
|
+
interval=args.interval,
|
35
|
+
overwrite=args.overwrite,
|
36
|
+
permission=args.permission
|
37
|
+
)
|
38
|
+
if failed_upload:
|
39
|
+
print("Failed to upload:")
|
40
|
+
for path in failed_upload:
|
41
|
+
print(f" {path}")
|
42
|
+
else:
|
43
|
+
with open(args.src, 'rb') as f:
|
44
|
+
connector.put(
|
45
|
+
args.dst,
|
46
|
+
f.read(),
|
47
|
+
overwrite=args.overwrite,
|
48
|
+
permission=args.permission
|
49
|
+
)
|
50
|
+
else:
|
51
|
+
raise NotImplementedError(f"Command {args.command} not implemented.")
|
52
|
+
|
@@ -0,0 +1,60 @@
|
|
1
|
+
import os, time
|
2
|
+
from threading import Lock
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
4
|
+
from .api import Connector
|
5
|
+
|
6
|
+
def upload_directory(
|
7
|
+
connector: Connector,
|
8
|
+
directory: str,
|
9
|
+
path: str,
|
10
|
+
n_concurrent: int = 1,
|
11
|
+
n_reties: int = 0,
|
12
|
+
interval: float = 0,
|
13
|
+
verbose: bool = False,
|
14
|
+
**put_kwargs
|
15
|
+
) -> list[str]:
|
16
|
+
assert path.endswith('/'), "Path must end with a slash."
|
17
|
+
if path.startswith('/'):
|
18
|
+
path = path[1:]
|
19
|
+
|
20
|
+
_counter = 0
|
21
|
+
_counter_lock = Lock()
|
22
|
+
|
23
|
+
faild_files = []
|
24
|
+
def put_file(file_path):
|
25
|
+
with _counter_lock:
|
26
|
+
nonlocal _counter
|
27
|
+
_counter += 1
|
28
|
+
this_count = _counter
|
29
|
+
dst_path = f"{path}{os.path.relpath(file_path, directory)}"
|
30
|
+
if verbose:
|
31
|
+
print(f"[{this_count}] Uploading {file_path} to {dst_path}")
|
32
|
+
|
33
|
+
this_try = 0
|
34
|
+
with open(file_path, 'rb') as f:
|
35
|
+
blob = f.read()
|
36
|
+
|
37
|
+
while this_try <= n_reties:
|
38
|
+
try:
|
39
|
+
connector.put(dst_path, blob, **put_kwargs)
|
40
|
+
break
|
41
|
+
except Exception as e:
|
42
|
+
if isinstance(e, KeyboardInterrupt):
|
43
|
+
raise e
|
44
|
+
if verbose:
|
45
|
+
print(f"[{this_count}] Error uploading {file_path}: {e}, retrying...")
|
46
|
+
this_try += 1
|
47
|
+
finally:
|
48
|
+
time.sleep(interval)
|
49
|
+
|
50
|
+
if this_try > n_reties:
|
51
|
+
faild_files.append(file_path)
|
52
|
+
if verbose:
|
53
|
+
print(f"[{this_count}] Failed to upload {file_path} after {n_reties} retries.")
|
54
|
+
|
55
|
+
with ThreadPoolExecutor(n_concurrent) as executor:
|
56
|
+
for root, dirs, files in os.walk(directory):
|
57
|
+
for file in files:
|
58
|
+
executor.submit(put_file, os.path.join(root, file))
|
59
|
+
|
60
|
+
return faild_files
|
@@ -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,9 +34,14 @@ class Connector:
|
|
34
34
|
return response
|
35
35
|
return f
|
36
36
|
|
37
|
-
def put(self, path: str, file_data: bytes):
|
37
|
+
def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, overwrite: bool = False):
|
38
38
|
"""Uploads a file to the specified path."""
|
39
|
-
|
39
|
+
if path.startswith('/'):
|
40
|
+
path = path[1:]
|
41
|
+
response = self._fetch('PUT', path, search_params={
|
42
|
+
'permission': int(permission),
|
43
|
+
'overwrite': overwrite
|
44
|
+
})(
|
40
45
|
data=file_data,
|
41
46
|
headers={'Content-Type': 'application/octet-stream'}
|
42
47
|
)
|
@@ -58,11 +63,14 @@ class Connector:
|
|
58
63
|
path = path[1:]
|
59
64
|
self._fetch('DELETE', path)()
|
60
65
|
|
61
|
-
def get_metadata(self, path: str) -> Optional[FileRecord]:
|
66
|
+
def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
|
62
67
|
"""Gets the metadata for the file at the specified path."""
|
63
68
|
try:
|
64
|
-
response = self._fetch('GET', '_api/
|
65
|
-
|
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())
|
66
74
|
except requests.exceptions.HTTPError as e:
|
67
75
|
if e.response.status_code == 404:
|
68
76
|
return None
|
@@ -75,13 +83,13 @@ class Connector:
|
|
75
83
|
|
76
84
|
def set_file_permission(self, path: str, permission: int | FileReadPermission):
|
77
85
|
"""Sets the file permission for the specified path."""
|
78
|
-
self._fetch('POST', '_api/
|
86
|
+
self._fetch('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
|
79
87
|
headers={'Content-Type': 'application/www-form-urlencoded'}
|
80
88
|
)
|
81
89
|
|
82
90
|
def move_file(self, path: str, new_path: str):
|
83
91
|
"""Moves a file to a new location."""
|
84
|
-
self._fetch('POST', '_api/
|
92
|
+
self._fetch('POST', '_api/meta', {'path': path, 'new_path': new_path})(
|
85
93
|
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
86
94
|
)
|
87
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")
|
@@ -162,7 +176,12 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
|
|
162
176
|
|
163
177
|
@router_fs.put("/{path:path}")
|
164
178
|
@handle_exception
|
165
|
-
async def put_file(
|
179
|
+
async def put_file(
|
180
|
+
request: Request,
|
181
|
+
path: str,
|
182
|
+
overwrite: Optional[bool] = False,
|
183
|
+
permission: int = 0,
|
184
|
+
user: UserRecord = Depends(get_current_user)):
|
166
185
|
path = ensure_uri_compnents(path)
|
167
186
|
if user.id == 0:
|
168
187
|
logger.debug("Reject put request from DECOY_USER")
|
@@ -182,8 +201,10 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
|
|
182
201
|
exists_flag = False
|
183
202
|
file_record = await conn.file.get_file_record(path)
|
184
203
|
if file_record:
|
185
|
-
|
204
|
+
if not overwrite:
|
205
|
+
raise HTTPException(status_code=409, detail="File exists")
|
186
206
|
# remove the old file
|
207
|
+
exists_flag = True
|
187
208
|
await conn.delete_file(path)
|
188
209
|
|
189
210
|
# check content-type
|
@@ -191,20 +212,25 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
|
|
191
212
|
logger.debug(f"Content-Type: {content_type}")
|
192
213
|
if content_type == "application/json":
|
193
214
|
body = await request.json()
|
194
|
-
|
215
|
+
blobs = json.dumps(body).encode('utf-8')
|
195
216
|
elif content_type == "application/x-www-form-urlencoded":
|
196
217
|
# may not work...
|
197
218
|
body = await request.form()
|
198
219
|
file = body.get("file")
|
199
220
|
if isinstance(file, str) or file is None:
|
200
221
|
raise HTTPException(status_code=400, detail="Invalid form data, file required")
|
201
|
-
|
222
|
+
blobs = await file.read()
|
202
223
|
elif content_type == "application/octet-stream":
|
203
|
-
|
204
|
-
|
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))
|
205
232
|
else:
|
206
|
-
|
207
|
-
await conn.save_file(user.id, path, body)
|
233
|
+
await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
|
208
234
|
|
209
235
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
|
210
236
|
if exists_flag:
|
@@ -282,19 +308,18 @@ async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
|
|
282
308
|
}
|
283
309
|
)
|
284
310
|
|
285
|
-
@router_api.get("/
|
311
|
+
@router_api.get("/meta")
|
286
312
|
@handle_exception
|
287
313
|
async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user)):
|
288
314
|
logger.info(f"GET meta({path}), user: {user.username}")
|
289
|
-
if path.endswith("/"):
|
290
|
-
raise HTTPException(status_code=400, detail="Invalid path")
|
291
315
|
path = ensure_uri_compnents(path)
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
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
|
296
321
|
|
297
|
-
@router_api.post("/
|
322
|
+
@router_api.post("/meta")
|
298
323
|
@handle_exception
|
299
324
|
async def update_file_meta(
|
300
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,12 +12,14 @@ 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]
|
18
19
|
lfss-serve = "lfss.cli.serve:main"
|
19
20
|
lfss-user = "lfss.cli.user:main"
|
20
21
|
lfss-panel = "lfss.cli.panel:main"
|
22
|
+
lfss-cli = "lfss.cli.cli:main"
|
21
23
|
|
22
24
|
[build-system]
|
23
25
|
requires = ["poetry-core>=1.0.0"]
|
lfss-0.4.0/lfss/src/__init__.py
DELETED
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
|