lfss 0.8.0__py3-none-any.whl → 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- frontend/api.js +59 -2
- frontend/scripts.js +5 -5
- lfss/api/__init__.py +21 -7
- lfss/api/connector.py +37 -0
- lfss/src/config.py +1 -1
- lfss/src/database.py +49 -42
- lfss/src/server.py +81 -53
- {lfss-0.8.0.dist-info → lfss-0.8.1.dist-info}/METADATA +2 -1
- {lfss-0.8.0.dist-info → lfss-0.8.1.dist-info}/RECORD +11 -11
- {lfss-0.8.0.dist-info → lfss-0.8.1.dist-info}/WHEEL +0 -0
- {lfss-0.8.0.dist-info → lfss-0.8.1.dist-info}/entry_points.txt +0 -0
frontend/api.js
CHANGED
@@ -73,7 +73,8 @@ export default class Connector {
|
|
73
73
|
method: 'PUT',
|
74
74
|
headers: {
|
75
75
|
'Authorization': 'Bearer ' + this.config.token,
|
76
|
-
'Content-Type': 'application/octet-stream'
|
76
|
+
'Content-Type': 'application/octet-stream',
|
77
|
+
'Content-Length': fileBytes.byteLength
|
77
78
|
},
|
78
79
|
body: fileBytes
|
79
80
|
});
|
@@ -83,6 +84,38 @@ export default class Connector {
|
|
83
84
|
return (await res.json()).url;
|
84
85
|
}
|
85
86
|
|
87
|
+
/**
|
88
|
+
* @param {string} path - the path to the file (url)
|
89
|
+
* @param {File} file - the file to upload
|
90
|
+
* @returns {Promise<string>} - the promise of the request, the url of the file
|
91
|
+
*/
|
92
|
+
async post(path, file, {
|
93
|
+
conflict = 'abort',
|
94
|
+
permission = 0
|
95
|
+
} = {}){
|
96
|
+
if (path.startsWith('/')){ path = path.slice(1); }
|
97
|
+
const dst = new URL(this.config.endpoint + '/' + path);
|
98
|
+
dst.searchParams.append('conflict', conflict);
|
99
|
+
dst.searchParams.append('permission', permission);
|
100
|
+
// post as multipart form data
|
101
|
+
const formData = new FormData();
|
102
|
+
formData.append('file', file);
|
103
|
+
const res = await fetch(dst.toString(), {
|
104
|
+
method: 'POST',
|
105
|
+
// don't include the content type, let the browser handle it
|
106
|
+
// https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/
|
107
|
+
headers: {
|
108
|
+
'Authorization': 'Bearer ' + this.config.token,
|
109
|
+
},
|
110
|
+
body: formData
|
111
|
+
});
|
112
|
+
|
113
|
+
if (res.status != 200 && res.status != 201){
|
114
|
+
throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await res.json()}`);
|
115
|
+
}
|
116
|
+
return (await res.json()).url;
|
117
|
+
}
|
118
|
+
|
86
119
|
/**
|
87
120
|
* @param {string} path - the path to the file (url), should end with .json
|
88
121
|
* @param {Objec} data - the data to upload
|
@@ -422,4 +455,28 @@ export async function listPath(conn, path, {
|
|
422
455
|
dirs: dirCount,
|
423
456
|
files: fileCount
|
424
457
|
}];
|
425
|
-
};
|
458
|
+
};
|
459
|
+
|
460
|
+
/**
|
461
|
+
* a function to wrap the upload function into one
|
462
|
+
* it will return the url of the file
|
463
|
+
*
|
464
|
+
* @typedef {Object} UploadOptions
|
465
|
+
* @property {string} conflict - the conflict resolution strategy, can be 'abort', 'replace', 'rename'
|
466
|
+
* @property {number} permission - the permission of the file, can be 0, 1, 2, 3
|
467
|
+
*
|
468
|
+
* @param {Connector} conn - the connector to the API
|
469
|
+
* @param {string} path - the path to the file (url)
|
470
|
+
* @param {File} file - the file to upload
|
471
|
+
* @param {UploadOptions} options - the options for the request
|
472
|
+
* @returns {Promise<string>} - the promise of the request, the url of the file
|
473
|
+
*/
|
474
|
+
export async function uploadFile(conn, path, file, {
|
475
|
+
conflict = 'abort',
|
476
|
+
permission = 0
|
477
|
+
} = {}){
|
478
|
+
if (file.size < 1024 * 1024 * 10){
|
479
|
+
return await conn.put(path, file, {conflict, permission});
|
480
|
+
}
|
481
|
+
return await conn.post(path, file, {conflict, permission});
|
482
|
+
}
|
frontend/scripts.js
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
import { permMap, listPath } from './api.js';
|
1
|
+
import { permMap, listPath, uploadFile } from './api.js';
|
2
2
|
import { showFloatingWindowLineInput, showPopup } from './popup.js';
|
3
3
|
import { formatSize, decodePathURI, ensurePathURI, getRandomString, cvtGMT2Local, debounce, encodePathURI, asHtmlText } from './utils.js';
|
4
4
|
import { showInfoPanel, showDirInfoPanel } from './info.js';
|
@@ -132,7 +132,7 @@ uploadButton.addEventListener('click', () => {
|
|
132
132
|
}
|
133
133
|
path = path + fileName;
|
134
134
|
showPopup('Uploading...', {level: 'info', timeout: 3000});
|
135
|
-
conn
|
135
|
+
uploadFile(conn, path, file, {'conflict': 'overwrite'})
|
136
136
|
.then(() => {
|
137
137
|
refreshFileList();
|
138
138
|
uploadFileNameInput.value = '';
|
@@ -178,10 +178,10 @@ Are you sure you want to proceed?
|
|
178
178
|
`)){ return; }
|
179
179
|
|
180
180
|
let counter = 0;
|
181
|
-
async function
|
181
|
+
async function uploadFileFn(...args){
|
182
182
|
const [file, path] = args;
|
183
183
|
try{
|
184
|
-
await conn
|
184
|
+
await uploadFile(conn, path, file, {conflict: 'overwrite'});
|
185
185
|
}
|
186
186
|
catch (err){
|
187
187
|
showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
|
@@ -194,7 +194,7 @@ Are you sure you want to proceed?
|
|
194
194
|
for (let i = 0; i < files.length; i++){
|
195
195
|
const file = files[i];
|
196
196
|
const path = dstPath + file.name;
|
197
|
-
promises.push(
|
197
|
+
promises.push(uploadFileFn(file, path));
|
198
198
|
}
|
199
199
|
showPopup('Uploading multiple files...', {level: 'info', timeout: 3000});
|
200
200
|
Promise.all(promises).then(
|
lfss/api/__init__.py
CHANGED
@@ -18,9 +18,13 @@ def upload_file(
|
|
18
18
|
error_msg = ""
|
19
19
|
while this_try <= n_retries:
|
20
20
|
try:
|
21
|
-
|
22
|
-
|
23
|
-
|
21
|
+
fsize = os.path.getsize(file_path)
|
22
|
+
if fsize < 32 * 1024 * 1024: # 32MB
|
23
|
+
with open(file_path, 'rb') as f:
|
24
|
+
blob = f.read()
|
25
|
+
connector.put(dst_url, blob, **put_kwargs)
|
26
|
+
else:
|
27
|
+
connector.post(dst_url, file_path, **put_kwargs)
|
24
28
|
break
|
25
29
|
except Exception as e:
|
26
30
|
if isinstance(e, KeyboardInterrupt):
|
@@ -97,14 +101,24 @@ def download_file(
|
|
97
101
|
print(f"File {file_path} already exists, skipping download.")
|
98
102
|
return True, error_msg
|
99
103
|
try:
|
100
|
-
|
101
|
-
if
|
104
|
+
fmeta = connector.get_metadata(src_url)
|
105
|
+
if fmeta is None:
|
102
106
|
error_msg = "File not found."
|
103
107
|
return False, error_msg
|
108
|
+
|
104
109
|
pathlib.Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
105
|
-
|
106
|
-
|
110
|
+
fsize = fmeta.file_size # type: ignore
|
111
|
+
if fsize < 32 * 1024 * 1024: # 32MB
|
112
|
+
blob = connector.get(src_url)
|
113
|
+
assert blob is not None
|
114
|
+
with open(file_path, 'wb') as f:
|
115
|
+
f.write(blob)
|
116
|
+
else:
|
117
|
+
with open(file_path, 'wb') as f:
|
118
|
+
for chunk in connector.get_stream(src_url):
|
119
|
+
f.write(chunk)
|
107
120
|
break
|
121
|
+
|
108
122
|
except Exception as e:
|
109
123
|
if isinstance(e, KeyboardInterrupt):
|
110
124
|
raise e
|
lfss/api/connector.py
CHANGED
@@ -4,6 +4,7 @@ import os
|
|
4
4
|
import requests
|
5
5
|
import requests.adapters
|
6
6
|
import urllib.parse
|
7
|
+
from tempfile import SpooledTemporaryFile
|
7
8
|
from lfss.src.error import PathNotFoundError
|
8
9
|
from lfss.src.datatype import (
|
9
10
|
FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents,
|
@@ -95,6 +96,42 @@ class Connector:
|
|
95
96
|
)
|
96
97
|
return response.json()
|
97
98
|
|
99
|
+
def post(self, path, file: str | bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
|
100
|
+
"""
|
101
|
+
Uploads a file to the specified path,
|
102
|
+
using the POST method, with form-data/multipart.
|
103
|
+
file can be a path to a file on disk, or bytes.
|
104
|
+
"""
|
105
|
+
|
106
|
+
# Skip ahead by checking if the file already exists
|
107
|
+
if conflict == 'skip-ahead':
|
108
|
+
exists = self.get_metadata(path)
|
109
|
+
if exists is None:
|
110
|
+
conflict = 'skip'
|
111
|
+
else:
|
112
|
+
return {'status': 'skipped', 'path': path}
|
113
|
+
|
114
|
+
if isinstance(file, str):
|
115
|
+
assert os.path.exists(file), "File does not exist on disk"
|
116
|
+
fsize = os.path.getsize(file)
|
117
|
+
|
118
|
+
with open(file, 'rb') if isinstance(file, str) else SpooledTemporaryFile(max_size=1024*1024*32) as fp:
|
119
|
+
|
120
|
+
if isinstance(file, bytes):
|
121
|
+
fsize = len(file)
|
122
|
+
fp.write(file)
|
123
|
+
fp.seek(0)
|
124
|
+
|
125
|
+
# https://stackoverflow.com/questions/12385179/
|
126
|
+
print(f"Uploading {fsize} bytes")
|
127
|
+
response = self._fetch_factory('POST', path, search_params={
|
128
|
+
'permission': int(permission),
|
129
|
+
'conflict': conflict
|
130
|
+
})(
|
131
|
+
files={'file': fp},
|
132
|
+
)
|
133
|
+
return response.json()
|
134
|
+
|
98
135
|
def put_json(self, path: str, data: dict, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
|
99
136
|
"""Uploads a JSON file to the specified path."""
|
100
137
|
assert path.endswith('.json'), "Path must end with .json"
|
lfss/src/config.py
CHANGED
@@ -18,7 +18,7 @@ if __env_large_file is not None:
|
|
18
18
|
LARGE_FILE_BYTES = parse_storage_size(__env_large_file)
|
19
19
|
else:
|
20
20
|
LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
|
21
|
-
|
21
|
+
MAX_MEM_FILE_BYTES = 128 * 1024 * 1024 # 128MB
|
22
22
|
MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
|
23
23
|
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming (on large files)
|
24
24
|
|
lfss/src/database.py
CHANGED
@@ -8,13 +8,14 @@ import zipfile, io, asyncio
|
|
8
8
|
|
9
9
|
import aiosqlite, aiofiles
|
10
10
|
import aiofiles.os
|
11
|
+
import mimetypes, mimesniff
|
11
12
|
|
12
13
|
from .connection_pool import execute_sql, unique_cursor, transaction
|
13
14
|
from .datatype import (
|
14
15
|
UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents,
|
15
16
|
FileSortKey, DirSortKey, isValidFileSortKey, isValidDirSortKey
|
16
17
|
)
|
17
|
-
from .config import LARGE_BLOB_DIR, CHUNK_SIZE
|
18
|
+
from .config import LARGE_BLOB_DIR, CHUNK_SIZE, LARGE_FILE_BYTES, MAX_MEM_FILE_BYTES
|
18
19
|
from .log import get_logger
|
19
20
|
from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async
|
20
21
|
from .error import *
|
@@ -285,8 +286,7 @@ class FileConn(DBObjectBase):
|
|
285
286
|
async def user_size(self, user_id: int) -> int:
|
286
287
|
cursor = await self.cur.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, ))
|
287
288
|
res = await cursor.fetchone()
|
288
|
-
if res is None:
|
289
|
-
return -1
|
289
|
+
if res is None: return 0
|
290
290
|
return res[0]
|
291
291
|
async def _user_size_inc(self, user_id: int, inc: int):
|
292
292
|
self.logger.debug(f"Increasing user {user_id} size by {inc}")
|
@@ -534,59 +534,66 @@ class Database:
|
|
534
534
|
|
535
535
|
async def save_file(
|
536
536
|
self, u: int | str, url: str,
|
537
|
-
|
537
|
+
blob_stream: AsyncIterable[bytes],
|
538
538
|
permission: FileReadPermission = FileReadPermission.UNSET,
|
539
|
-
mime_type: str =
|
540
|
-
):
|
539
|
+
mime_type: Optional[str] = None
|
540
|
+
) -> int:
|
541
541
|
"""
|
542
|
-
|
542
|
+
Save a file to the database.
|
543
|
+
Will check file size and user storage limit,
|
544
|
+
should check permission before calling this method.
|
543
545
|
"""
|
544
546
|
validate_url(url)
|
545
547
|
async with unique_cursor() as cur:
|
546
548
|
user = await get_user(cur, u)
|
547
|
-
|
548
|
-
return
|
549
|
-
|
550
|
-
# check if the user is the owner of the path, or is admin
|
551
|
-
if url.startswith('/'):
|
552
|
-
url = url[1:]
|
553
|
-
first_component = url.split('/')[0]
|
554
|
-
if first_component != user.username:
|
555
|
-
if not user.is_admin:
|
556
|
-
raise PermissionDeniedError(f"Permission denied: {user.username} cannot write to {url}")
|
557
|
-
else:
|
558
|
-
if await get_user(cur, first_component) is None:
|
559
|
-
raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
|
549
|
+
assert user is not None, f"User {u} not found"
|
560
550
|
|
561
551
|
fconn_r = FileConn(cur)
|
562
552
|
user_size_used = await fconn_r.user_size(user.id)
|
563
553
|
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
async with transaction() as w_cur:
|
571
|
-
fconn_w = FileConn(w_cur)
|
572
|
-
await fconn_w.set_file_blob(f_id, blob)
|
573
|
-
await fconn_w.set_file_record(
|
574
|
-
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
575
|
-
permission=permission, external=False, mime_type=mime_type)
|
576
|
-
else:
|
577
|
-
assert isinstance(blob, AsyncIterable)
|
578
|
-
f_id = uuid.uuid4().hex
|
579
|
-
file_size = await FileConn.set_file_blob_external(f_id, blob)
|
554
|
+
f_id = uuid.uuid4().hex
|
555
|
+
async with aiofiles.tempfile.SpooledTemporaryFile(max_size=MAX_MEM_FILE_BYTES) as f:
|
556
|
+
async for chunk in blob_stream:
|
557
|
+
await f.write(chunk)
|
558
|
+
file_size = await f.tell()
|
580
559
|
if user_size_used + file_size > user.max_storage:
|
581
|
-
await FileConn.delete_file_blob_external(f_id)
|
582
560
|
raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
|
583
561
|
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
562
|
+
# check mime type
|
563
|
+
if mime_type is None:
|
564
|
+
fname = url.split('/')[-1]
|
565
|
+
mime_type, _ = mimetypes.guess_type(fname)
|
566
|
+
if mime_type is None:
|
567
|
+
await f.seek(0)
|
568
|
+
mime_type = mimesniff.what(await f.read(1024))
|
569
|
+
if mime_type is None:
|
570
|
+
mime_type = 'application/octet-stream'
|
571
|
+
await f.seek(0)
|
572
|
+
|
573
|
+
if file_size < LARGE_FILE_BYTES:
|
574
|
+
blob = await f.read()
|
575
|
+
async with transaction() as w_cur:
|
576
|
+
fconn_w = FileConn(w_cur)
|
577
|
+
await fconn_w.set_file_blob(f_id, blob)
|
578
|
+
await fconn_w.set_file_record(
|
579
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
580
|
+
permission=permission, external=False, mime_type=mime_type)
|
581
|
+
|
582
|
+
else:
|
583
|
+
async def blob_stream_tempfile():
|
584
|
+
nonlocal f
|
585
|
+
while True:
|
586
|
+
chunk = await f.read(CHUNK_SIZE)
|
587
|
+
if not chunk: break
|
588
|
+
yield chunk
|
589
|
+
await FileConn.set_file_blob_external(f_id, blob_stream_tempfile())
|
590
|
+
async with transaction() as w_cur:
|
591
|
+
await FileConn(w_cur).set_file_record(
|
592
|
+
url, owner_id=user.id, file_id=f_id, file_size=file_size,
|
593
|
+
permission=permission, external=True, mime_type=mime_type)
|
594
|
+
|
589
595
|
await delayed_log_activity(user.username)
|
596
|
+
return file_size
|
590
597
|
|
591
598
|
async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
|
592
599
|
validate_url(url)
|
lfss/src/server.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
from typing import Optional, Literal
|
2
2
|
from functools import wraps
|
3
3
|
|
4
|
-
from fastapi import FastAPI, APIRouter, Depends, Request, Response
|
4
|
+
from fastapi import FastAPI, APIRouter, Depends, Request, Response, UploadFile
|
5
5
|
from fastapi.responses import StreamingResponse
|
6
6
|
from fastapi.exceptions import HTTPException
|
7
7
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
@@ -9,16 +9,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
9
9
|
import mimesniff
|
10
10
|
|
11
11
|
import asyncio, json, time
|
12
|
-
import mimetypes
|
13
12
|
from contextlib import asynccontextmanager
|
14
13
|
|
15
14
|
from .error import *
|
16
15
|
from .log import get_logger
|
17
16
|
from .stat import RequestDB
|
18
|
-
from .config import MAX_BUNDLE_BYTES,
|
17
|
+
from .config import MAX_BUNDLE_BYTES, MAX_MEM_FILE_BYTES, LARGE_FILE_BYTES, CHUNK_SIZE
|
19
18
|
from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
|
20
19
|
from .connection_pool import global_connection_init, global_connection_close, unique_cursor
|
21
|
-
from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn, delayed_log_activity
|
20
|
+
from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn, delayed_log_activity, get_user
|
22
21
|
from .datatype import (
|
23
22
|
FileReadPermission, FileRecord, UserRecord, PathContents,
|
24
23
|
FileSortKey, DirSortKey
|
@@ -246,18 +245,21 @@ async def put_file(
|
|
246
245
|
path: str,
|
247
246
|
conflict: Literal["overwrite", "skip", "abort"] = "abort",
|
248
247
|
permission: int = 0,
|
249
|
-
user: UserRecord = Depends(registered_user)
|
248
|
+
user: UserRecord = Depends(registered_user)
|
249
|
+
):
|
250
250
|
path = ensure_uri_compnents(path)
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
251
|
+
assert not path.endswith("/"), "Path must not end with /"
|
252
|
+
if not path.startswith(f"{user.username}/"):
|
253
|
+
if not user.is_admin:
|
254
|
+
logger.debug(f"Reject put request from {user.username} to {path}")
|
255
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
256
|
+
else:
|
257
|
+
first_comp = path.split("/")[0]
|
258
|
+
async with unique_cursor() as c:
|
259
|
+
uconn = UserConn(c)
|
260
|
+
owner = await uconn.get_user(first_comp)
|
261
|
+
if not owner:
|
262
|
+
raise HTTPException(status_code=404, detail="Owner not found")
|
261
263
|
|
262
264
|
logger.info(f"PUT {path}, user: {user.username}")
|
263
265
|
exists_flag = False
|
@@ -280,47 +282,73 @@ async def put_file(
|
|
280
282
|
# check content-type
|
281
283
|
content_type = request.headers.get("Content-Type")
|
282
284
|
logger.debug(f"Content-Type: {content_type}")
|
283
|
-
if content_type == "application/json":
|
284
|
-
|
285
|
-
blobs = json.dumps(body).encode('utf-8')
|
286
|
-
elif content_type == "application/x-www-form-urlencoded":
|
287
|
-
# may not work...
|
288
|
-
body = await request.form()
|
289
|
-
file = body.get("file")
|
290
|
-
if isinstance(file, str) or file is None:
|
291
|
-
raise HTTPException(status_code=400, detail="Invalid form data, file required")
|
292
|
-
blobs = await file.read()
|
293
|
-
elif content_type == "application/octet-stream":
|
294
|
-
blobs = await request.body()
|
295
|
-
else:
|
296
|
-
blobs = await request.body()
|
285
|
+
if not (content_type == "application/octet-stream" or content_type == "application/json"):
|
286
|
+
raise HTTPException(status_code=415, detail="Unsupported content type, put request must be application/json or application/octet-stream")
|
297
287
|
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
if mime_t is None:
|
305
|
-
mime_t = "application/octet-stream"
|
306
|
-
|
307
|
-
if len(blobs) > LARGE_FILE_BYTES:
|
308
|
-
async def blob_reader():
|
309
|
-
for b in range(0, len(blobs), CHUNK_SIZE):
|
310
|
-
yield blobs[b:b+CHUNK_SIZE]
|
311
|
-
await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission), mime_type = mime_t)
|
312
|
-
else:
|
313
|
-
await db.save_file(user.id, path, blobs, permission = FileReadPermission(permission), mime_type=mime_t)
|
288
|
+
async def blob_reader():
|
289
|
+
nonlocal request
|
290
|
+
async for chunk in request.stream():
|
291
|
+
yield chunk
|
292
|
+
|
293
|
+
await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
|
314
294
|
|
315
295
|
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
|
316
|
-
if exists_flag
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
296
|
+
return Response(status_code=200 if exists_flag else 201, headers={
|
297
|
+
"Content-Type": "application/json",
|
298
|
+
}, content=json.dumps({"url": path}))
|
299
|
+
|
300
|
+
# using form-data instead of raw body
|
301
|
+
@router_fs.post("/{path:path}")
|
302
|
+
@handle_exception
|
303
|
+
async def post_file(
|
304
|
+
path: str,
|
305
|
+
file: UploadFile,
|
306
|
+
conflict: Literal["overwrite", "skip", "abort"] = "abort",
|
307
|
+
permission: int = 0,
|
308
|
+
user: UserRecord = Depends(registered_user)
|
309
|
+
):
|
310
|
+
path = ensure_uri_compnents(path)
|
311
|
+
assert not path.endswith("/"), "Path must not end with /"
|
312
|
+
if not path.startswith(f"{user.username}/"):
|
313
|
+
if not user.is_admin:
|
314
|
+
logger.debug(f"Reject put request from {user.username} to {path}")
|
315
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
316
|
+
else:
|
317
|
+
first_comp = path.split("/")[0]
|
318
|
+
async with unique_cursor() as conn:
|
319
|
+
uconn = UserConn(conn)
|
320
|
+
owner = await uconn.get_user(first_comp)
|
321
|
+
if not owner:
|
322
|
+
raise HTTPException(status_code=404, detail="Owner not found")
|
323
|
+
|
324
|
+
logger.info(f"POST {path}, user: {user.username}")
|
325
|
+
exists_flag = False
|
326
|
+
async with unique_cursor() as conn:
|
327
|
+
fconn = FileConn(conn)
|
328
|
+
file_record = await fconn.get_file_record(path)
|
329
|
+
|
330
|
+
if file_record:
|
331
|
+
if conflict == "abort":
|
332
|
+
raise HTTPException(status_code=409, detail="File exists")
|
333
|
+
if conflict == "skip":
|
334
|
+
return Response(status_code=200, headers={
|
335
|
+
"Content-Type": "application/json",
|
336
|
+
}, content=json.dumps({"url": path}))
|
337
|
+
exists_flag = True
|
338
|
+
if not user.is_admin and not file_record.owner_id == user.id:
|
339
|
+
raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
|
340
|
+
await db.delete_file(path)
|
341
|
+
|
342
|
+
async def blob_reader():
|
343
|
+
nonlocal file
|
344
|
+
while (chunk := await file.read(CHUNK_SIZE)):
|
345
|
+
yield chunk
|
346
|
+
|
347
|
+
await db.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
|
348
|
+
return Response(status_code=200 if exists_flag else 201, headers={
|
349
|
+
"Content-Type": "application/json",
|
350
|
+
}, content=json.dumps({"url": path}))
|
351
|
+
|
324
352
|
|
325
353
|
@router_fs.delete("/{path:path}")
|
326
354
|
@handle_exception
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.8.
|
3
|
+
Version: 0.8.1
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li, mengxun
|
@@ -16,6 +16,7 @@ Requires-Dist: aiosqlite (==0.*)
|
|
16
16
|
Requires-Dist: fastapi (==0.*)
|
17
17
|
Requires-Dist: mimesniff (==1.*)
|
18
18
|
Requires-Dist: pillow
|
19
|
+
Requires-Dist: python-multipart
|
19
20
|
Requires-Dist: requests (==2.*)
|
20
21
|
Requires-Dist: uvicorn (==0.*)
|
21
22
|
Project-URL: Repository, https://github.com/MenxLi/lfss
|
@@ -1,7 +1,7 @@
|
|
1
1
|
Readme.md,sha256=LpbTvUWjCOv4keMNDrZvEnNAmCQnvaxvlq2srWixXn0,1299
|
2
2
|
docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
|
3
3
|
docs/Permission.md,sha256=9r9nEmhqfz18RTS8FI0fZ9F0a31r86OoAyx3EQxxpk0,2317
|
4
|
-
frontend/api.js,sha256=
|
4
|
+
frontend/api.js,sha256=wUJNAkL8QigAiwR_jaMPUhCQEsL-lp0wZ6XeueYgunE,18049
|
5
5
|
frontend/index.html,sha256=-k0bJ5FRqdl_H-O441D_H9E-iejgRCaL_z5UeYaS2qc,3384
|
6
6
|
frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
|
7
7
|
frontend/info.js,sha256=WhOGaeqMoezEAfg4nIpK26hvejC7AZ-ZDLiJmRj0kDk,5758
|
@@ -9,14 +9,14 @@ frontend/login.css,sha256=VMM0QfbDFYerxKWKSGhMI1yg5IRBXg0TTdLJEEhQZNk,355
|
|
9
9
|
frontend/login.js,sha256=QoO8yKmBHDVP-ZomCMOaV7xVUVIhpl7esJrb6T5aHQE,2466
|
10
10
|
frontend/popup.css,sha256=TJZYFW1ZcdD1IVTlNPYNtMWKPbN6XDbQ4hKBOFK8uLg,1284
|
11
11
|
frontend/popup.js,sha256=3PgaGZmxSdV1E-D_MWgcR7aHWkcsHA1BNKSOkmP66tA,5191
|
12
|
-
frontend/scripts.js,sha256=
|
12
|
+
frontend/scripts.js,sha256=2YoMhrcAkI1bHihD_2EK6uCHZ1s0DiIR3FzZsh79x9A,21729
|
13
13
|
frontend/state.js,sha256=vbNL5DProRKmSEY7xu9mZH6IY0PBenF8WGxPtGgDnLI,1680
|
14
14
|
frontend/styles.css,sha256=xcNLqI3KBsY5TLnku8UIP0Jfr7QLajr1_KNlZj9eheM,4935
|
15
15
|
frontend/thumb.css,sha256=rNsx766amYS2DajSQNabhpQ92gdTpNoQKmV69OKvtpI,295
|
16
16
|
frontend/thumb.js,sha256=RQ_whXNwmkdG4SEbNQGeh488YYzqwoNYDc210hPeuhQ,5703
|
17
17
|
frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
|
18
|
-
lfss/api/__init__.py,sha256=
|
19
|
-
lfss/api/connector.py,sha256=
|
18
|
+
lfss/api/__init__.py,sha256=MRzwISePOdq3of9IWGryVWX6coGkxeJ3OEh42Se4IYc,6029
|
19
|
+
lfss/api/connector.py,sha256=tmcgKswE0sktBhanEDEc6mpuJA0de7C-DS2YqlpxHX4,10743
|
20
20
|
lfss/cli/balance.py,sha256=R2rbO2tg9TVnnQIVeU0GJVeMS-5LDhEdk4mbOE9qGq0,4121
|
21
21
|
lfss/cli/cli.py,sha256=8VKe41m_LhVSFxGlvgBxdz55sjscLNbbkNX1fOnmES4,4618
|
22
22
|
lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
|
@@ -27,17 +27,17 @@ lfss/sql/init.sql,sha256=C-JtQAlaOjESI8uoF1Y_9dKukEVSw5Ll-7yA3gG-XHU,1210
|
|
27
27
|
lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
|
28
28
|
lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
29
29
|
lfss/src/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
|
30
|
-
lfss/src/config.py,sha256=
|
30
|
+
lfss/src/config.py,sha256=7k_6L_aG7vD-lVcmgpwMIg-ggpMMDNU8IbaB4y1effE,861
|
31
31
|
lfss/src/connection_pool.py,sha256=4YULPcmndy33FyBSo9w1fZjAlBX2q-xPP27xJOTA06I,5228
|
32
|
-
lfss/src/database.py,sha256=
|
32
|
+
lfss/src/database.py,sha256=Cexv6r9sZl29hWzFyL_J_kWz9roUbut6A246Zc4ORs0,35885
|
33
33
|
lfss/src/datatype.py,sha256=q2lc8BJaB2sS7gtqPMqxM625mckgI2SmvuqbadiObLY,2158
|
34
34
|
lfss/src/error.py,sha256=Bh_GUtuNsMSMIKbFreU4CfZzL96TZdNAIcgmDxvGbQ0,333
|
35
35
|
lfss/src/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
|
36
|
-
lfss/src/server.py,sha256=
|
36
|
+
lfss/src/server.py,sha256=mUu0WbiGdM08Jos7a4r_e9ND5sK_tNDEv1VGRPIHvLk,21206
|
37
37
|
lfss/src/stat.py,sha256=WlRiiAl0rHX9oPi3thi6K4GKn70WHpClSDCt7iZUow4,3197
|
38
38
|
lfss/src/thumb.py,sha256=qjCNMpnCozMuzkhm-2uAYy1eAuYTeWG6xqs-13HX-7k,3266
|
39
39
|
lfss/src/utils.py,sha256=nal2rpr00jq1PeFhGQXkvU0FIbtRhXTj8VmbeIyRyLI,5184
|
40
|
-
lfss-0.8.
|
41
|
-
lfss-0.8.
|
42
|
-
lfss-0.8.
|
43
|
-
lfss-0.8.
|
40
|
+
lfss-0.8.1.dist-info/METADATA,sha256=PMNu6iNXnpYU6Lyxvlr9-e-ypSj71dygYyqH3mTHzE0,2108
|
41
|
+
lfss-0.8.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
42
|
+
lfss-0.8.1.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
|
43
|
+
lfss-0.8.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|