lfss 0.7.10__py3-none-any.whl → 0.7.12__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/scripts.js +16 -3
- frontend/styles.css +13 -0
- frontend/utils.js +7 -0
- lfss/cli/serve.py +1 -1
- lfss/cli/vacuum.py +6 -8
- lfss/client/__init__.py +3 -3
- lfss/client/api.py +3 -2
- lfss/src/bounded_pool.py +44 -0
- lfss/src/server.py +63 -28
- lfss/src/stat.py +10 -2
- lfss/src/thumb.py +121 -0
- {lfss-0.7.10.dist-info → lfss-0.7.12.dist-info}/METADATA +3 -1
- {lfss-0.7.10.dist-info → lfss-0.7.12.dist-info}/RECORD +15 -13
- {lfss-0.7.10.dist-info → lfss-0.7.12.dist-info}/WHEEL +0 -0
- {lfss-0.7.10.dist-info → lfss-0.7.12.dist-info}/entry_points.txt +0 -0
frontend/scripts.js
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
import Connector from './api.js';
|
2
2
|
import { permMap } from './api.js';
|
3
3
|
import { showFloatingWindowLineInput, showPopup } from './popup.js';
|
4
|
-
import { formatSize, decodePathURI, ensurePathURI, getRandomString, cvtGMT2Local, debounce, encodePathURI } from './utils.js';
|
4
|
+
import { formatSize, decodePathURI, ensurePathURI, getRandomString, cvtGMT2Local, debounce, encodePathURI, asHtmlText } from './utils.js';
|
5
5
|
import { showInfoPanel, showDirInfoPanel } from './info.js';
|
6
6
|
|
7
7
|
const conn = new Connector();
|
@@ -251,7 +251,14 @@ function refreshFileList(){
|
|
251
251
|
onPathChange();
|
252
252
|
});
|
253
253
|
dirLink.href = '#';
|
254
|
-
|
254
|
+
const nameDiv = document.createElement('div');
|
255
|
+
nameDiv.classList.add('filename-container');
|
256
|
+
const thumbImg = document.createElement('img');
|
257
|
+
thumbImg.classList.add('thumb');
|
258
|
+
thumbImg.src = conn.config.endpoint + '/' + ensureSlashEnd(dir.url) + '?token=' + conn.config.token + '&thumb=true';
|
259
|
+
nameDiv.appendChild(thumbImg);
|
260
|
+
nameDiv.appendChild(dirLink);
|
261
|
+
nameTd.appendChild(nameDiv);
|
255
262
|
|
256
263
|
tr.appendChild(nameTd);
|
257
264
|
tbody.appendChild(tr);
|
@@ -343,7 +350,13 @@ function refreshFileList(){
|
|
343
350
|
const nameTd = document.createElement('td');
|
344
351
|
const plainUrl = decodePathURI(file.url);
|
345
352
|
const fileName = plainUrl.split('/').pop();
|
346
|
-
|
353
|
+
|
354
|
+
nameTd.innerHTML = `
|
355
|
+
<div class="filename-container">
|
356
|
+
<img class="thumb" src="${conn.config.endpoint}/${file.url}?token=${conn.config.token}&thumb=true" />
|
357
|
+
<span>${asHtmlText(fileName)}</span>
|
358
|
+
</div>
|
359
|
+
`
|
347
360
|
tr.appendChild(nameTd);
|
348
361
|
tbody.appendChild(tr);
|
349
362
|
}
|
frontend/styles.css
CHANGED
@@ -181,6 +181,19 @@ table#files tr td:nth-child(3), table#files tr td:nth-child(5){
|
|
181
181
|
width: 12%;
|
182
182
|
}
|
183
183
|
|
184
|
+
div.filename-container{
|
185
|
+
display: flex;
|
186
|
+
flex-direction: row;
|
187
|
+
align-items: center;
|
188
|
+
gap: 0.5rem;
|
189
|
+
height: 32px; /* same as thumbnail */
|
190
|
+
}
|
191
|
+
img.thumb{
|
192
|
+
max-height: 32px; /* smaller than backend */
|
193
|
+
max-width: 32px;
|
194
|
+
border-radius: 0.25rem;
|
195
|
+
}
|
196
|
+
|
184
197
|
label#upload-file-prefix{
|
185
198
|
width: 800px;
|
186
199
|
text-align: right;
|
frontend/utils.js
CHANGED
@@ -86,4 +86,11 @@ export function debounce(fn,wait){
|
|
86
86
|
if (timeout) clearTimeout(timeout);
|
87
87
|
timeout = setTimeout(() => fn.apply(context, args), wait);
|
88
88
|
}
|
89
|
+
}
|
90
|
+
|
91
|
+
export function asHtmlText(text){
|
92
|
+
const anonElem = document.createElement('div');
|
93
|
+
anonElem.textContent = text;
|
94
|
+
const htmlText = anonElem.innerHTML;
|
95
|
+
return htmlText;
|
89
96
|
}
|
lfss/cli/serve.py
CHANGED
@@ -24,7 +24,7 @@ def main():
|
|
24
24
|
log_config=default_logging_config
|
25
25
|
)
|
26
26
|
server = Server(config=config)
|
27
|
-
logger.info(f"Starting server at {args.host}:{args.port}, with {args.workers} workers")
|
27
|
+
logger.info(f"Starting server at http://{args.host}:{args.port}, with {args.workers} workers.")
|
28
28
|
server.run()
|
29
29
|
|
30
30
|
if __name__ == "__main__":
|
lfss/cli/vacuum.py
CHANGED
@@ -2,8 +2,8 @@
|
|
2
2
|
Vacuum the database and external storage to ensure that the storage is consistent and minimal.
|
3
3
|
"""
|
4
4
|
|
5
|
-
from lfss.src.config import LARGE_BLOB_DIR
|
6
|
-
import argparse, time
|
5
|
+
from lfss.src.config import LARGE_BLOB_DIR
|
6
|
+
import argparse, time
|
7
7
|
from functools import wraps
|
8
8
|
from asyncio import Semaphore
|
9
9
|
import aiofiles, asyncio
|
@@ -11,6 +11,7 @@ import aiofiles.os
|
|
11
11
|
from contextlib import contextmanager
|
12
12
|
from lfss.src.database import transaction, unique_cursor
|
13
13
|
from lfss.src.stat import RequestDB
|
14
|
+
from lfss.src.utils import now_stamp
|
14
15
|
from lfss.src.connection_pool import global_entrance
|
15
16
|
|
16
17
|
sem: Semaphore
|
@@ -68,12 +69,9 @@ async def vacuum_main(index: bool = False, blobs: bool = False):
|
|
68
69
|
|
69
70
|
async def vacuum_requests():
|
70
71
|
with indicator("VACUUM-requests"):
|
71
|
-
|
72
|
-
|
73
|
-
await req_db.shrink()
|
72
|
+
async with RequestDB().connect() as req_db:
|
73
|
+
await req_db.shrink(max_rows=1_000_000, time_before=now_stamp() - 7*24*60*60)
|
74
74
|
await req_db.conn.execute("VACUUM")
|
75
|
-
finally:
|
76
|
-
await req_db.close()
|
77
75
|
|
78
76
|
def main():
|
79
77
|
global sem
|
@@ -81,7 +79,7 @@ def main():
|
|
81
79
|
parser.add_argument("-j", "--jobs", type=int, default=2, help="Number of concurrent jobs")
|
82
80
|
parser.add_argument("-m", "--metadata", action="store_true", help="Vacuum metadata")
|
83
81
|
parser.add_argument("-d", "--data", action="store_true", help="Vacuum blobs")
|
84
|
-
parser.add_argument("-r", "--requests", action="store_true", help="Vacuum request logs")
|
82
|
+
parser.add_argument("-r", "--requests", action="store_true", help="Vacuum request logs to only keep at most recent 1M rows in 7 days")
|
85
83
|
args = parser.parse_args()
|
86
84
|
sem = Semaphore(args.jobs)
|
87
85
|
asyncio.run(vacuum_main(index=args.metadata, blobs=args.data))
|
lfss/client/__init__.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
import os, time, pathlib
|
2
2
|
from threading import Lock
|
3
|
-
from concurrent.futures import ThreadPoolExecutor
|
4
3
|
from .api import Connector
|
4
|
+
from ..src.bounded_pool import BoundedThreadPoolExecutor
|
5
5
|
|
6
6
|
def upload_file(
|
7
7
|
connector: Connector,
|
@@ -68,7 +68,7 @@ def upload_directory(
|
|
68
68
|
):
|
69
69
|
faild_files.append(file_path)
|
70
70
|
|
71
|
-
with
|
71
|
+
with BoundedThreadPoolExecutor(n_concurrent) as executor:
|
72
72
|
for root, dirs, files in os.walk(directory):
|
73
73
|
for file in files:
|
74
74
|
executor.submit(put_file, os.path.join(root, file))
|
@@ -149,7 +149,7 @@ def download_directory(
|
|
149
149
|
):
|
150
150
|
failed_files.append(src_url)
|
151
151
|
|
152
|
-
with
|
152
|
+
with BoundedThreadPoolExecutor(n_concurrent) as executor:
|
153
153
|
for file in connector.list_path(src_path, flat=True).files:
|
154
154
|
executor.submit(get_file, file.url)
|
155
155
|
return failed_files
|
lfss/client/api.py
CHANGED
@@ -31,8 +31,9 @@ class Connector:
|
|
31
31
|
headers.update({
|
32
32
|
'Authorization': f"Bearer {self.config['token']}",
|
33
33
|
})
|
34
|
-
|
35
|
-
|
34
|
+
with requests.Session() as s:
|
35
|
+
response = s.request(method, url, headers=headers, **kwargs)
|
36
|
+
response.raise_for_status()
|
36
37
|
return response
|
37
38
|
return f
|
38
39
|
|
lfss/src/bounded_pool.py
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
"""
|
2
|
+
Code modified from: https://github.com/mowshon/bounded_pool_executor
|
3
|
+
"""
|
4
|
+
|
5
|
+
import multiprocessing
|
6
|
+
import concurrent.futures
|
7
|
+
import multiprocessing.synchronize
|
8
|
+
import threading
|
9
|
+
from typing import Optional
|
10
|
+
|
11
|
+
class _BoundedPoolExecutor:
|
12
|
+
|
13
|
+
semaphore: Optional[multiprocessing.synchronize.BoundedSemaphore | threading.BoundedSemaphore] = None
|
14
|
+
_max_workers: int
|
15
|
+
|
16
|
+
def acquire(self):
|
17
|
+
assert self.semaphore is not None
|
18
|
+
self.semaphore.acquire()
|
19
|
+
|
20
|
+
def release(self, fn):
|
21
|
+
assert self.semaphore is not None
|
22
|
+
self.semaphore.release()
|
23
|
+
|
24
|
+
def submit(self, fn, *args, **kwargs):
|
25
|
+
self.acquire()
|
26
|
+
future = super().submit(fn, *args, **kwargs) # type: ignore
|
27
|
+
future.add_done_callback(self.release)
|
28
|
+
|
29
|
+
return future
|
30
|
+
|
31
|
+
|
32
|
+
class BoundedProcessPoolExecutor(_BoundedPoolExecutor, concurrent.futures.ProcessPoolExecutor):
|
33
|
+
|
34
|
+
def __init__(self, max_workers=None):
|
35
|
+
super().__init__(max_workers)
|
36
|
+
self.semaphore = multiprocessing.BoundedSemaphore(self._max_workers * 2)
|
37
|
+
|
38
|
+
|
39
|
+
class BoundedThreadPoolExecutor(_BoundedPoolExecutor, concurrent.futures.ThreadPoolExecutor):
|
40
|
+
|
41
|
+
def __init__(self, max_workers=None):
|
42
|
+
super().__init__(max_workers)
|
43
|
+
self.semaphore = threading.BoundedSemaphore(self._max_workers * 2)
|
44
|
+
|
lfss/src/server.py
CHANGED
@@ -19,6 +19,7 @@ from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_FILE_BYTES, CHUNK_SI
|
|
19
19
|
from .utils import ensure_uri_compnents, format_last_modified, now_stamp
|
20
20
|
from .connection_pool import global_connection_init, global_connection_close, unique_cursor
|
21
21
|
from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission, UserConn, FileConn, PathContents
|
22
|
+
from .thumb import get_thumb
|
22
23
|
|
23
24
|
logger = get_logger("server", term_level="DEBUG")
|
24
25
|
logger_failed_request = get_logger("failed_requests", term_level="INFO")
|
@@ -31,7 +32,6 @@ async def lifespan(app: FastAPI):
|
|
31
32
|
try:
|
32
33
|
await global_connection_init(n_read = 2)
|
33
34
|
await asyncio.gather(db.init(), req_conn.init())
|
34
|
-
await req_conn.shrink()
|
35
35
|
yield
|
36
36
|
await req_conn.commit()
|
37
37
|
finally:
|
@@ -50,7 +50,7 @@ def handle_exception(fn):
|
|
50
50
|
if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
|
51
51
|
if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
|
52
52
|
logger.error(f"Uncaptured error in {fn.__name__}: {e}")
|
53
|
-
raise
|
53
|
+
raise
|
54
54
|
return wrapper
|
55
55
|
|
56
56
|
async def get_credential_from_params(request: Request):
|
@@ -119,9 +119,59 @@ async def log_requests(request: Request, call_next):
|
|
119
119
|
|
120
120
|
router_fs = APIRouter(prefix="")
|
121
121
|
|
122
|
+
async def emit_thumbnail(
|
123
|
+
path: str, download: bool,
|
124
|
+
create_time: Optional[str] = None
|
125
|
+
):
|
126
|
+
if path.endswith("/"):
|
127
|
+
fname = path.split("/")[-2]
|
128
|
+
else:
|
129
|
+
fname = path.split("/")[-1]
|
130
|
+
thumb_blob, mime_type = await get_thumb(path)
|
131
|
+
disp = "inline" if not download else "attachment"
|
132
|
+
headers = {
|
133
|
+
"Content-Disposition": f"{disp}; filename={fname}.thumb.jpg",
|
134
|
+
"Content-Length": str(len(thumb_blob)),
|
135
|
+
}
|
136
|
+
if create_time is not None:
|
137
|
+
headers["Last-Modified"] = format_last_modified(create_time)
|
138
|
+
return Response(
|
139
|
+
content=thumb_blob, media_type=mime_type, headers=headers
|
140
|
+
)
|
141
|
+
async def emit_file(
|
142
|
+
file_record: FileRecord,
|
143
|
+
media_type: Optional[str] = None,
|
144
|
+
disposition = "attachment"
|
145
|
+
):
|
146
|
+
if media_type is None:
|
147
|
+
media_type = file_record.mime_type
|
148
|
+
path = file_record.url
|
149
|
+
fname = path.split("/")[-1]
|
150
|
+
if not file_record.external:
|
151
|
+
fblob = await db.read_file(path)
|
152
|
+
return Response(
|
153
|
+
content=fblob, media_type=media_type, headers={
|
154
|
+
"Content-Disposition": f"{disposition}; filename={fname}",
|
155
|
+
"Content-Length": str(len(fblob)),
|
156
|
+
"Last-Modified": format_last_modified(file_record.create_time)
|
157
|
+
}
|
158
|
+
)
|
159
|
+
else:
|
160
|
+
return StreamingResponse(
|
161
|
+
await db.read_file_stream(path), media_type=media_type, headers={
|
162
|
+
"Content-Disposition": f"{disposition}; filename={fname}",
|
163
|
+
"Content-Length": str(file_record.file_size),
|
164
|
+
"Last-Modified": format_last_modified(file_record.create_time)
|
165
|
+
}
|
166
|
+
)
|
167
|
+
|
122
168
|
@router_fs.get("/{path:path}")
|
123
169
|
@handle_exception
|
124
|
-
async def get_file(
|
170
|
+
async def get_file(
|
171
|
+
path: str,
|
172
|
+
download: bool = False, flat: bool = False, thumb: bool = False,
|
173
|
+
user: UserRecord = Depends(get_current_user)
|
174
|
+
):
|
125
175
|
path = ensure_uri_compnents(path)
|
126
176
|
|
127
177
|
# handle directory query
|
@@ -132,6 +182,9 @@ async def get_file(path: str, download: bool = False, flat: bool = False, user:
|
|
132
182
|
fconn = FileConn(conn)
|
133
183
|
if user.id == 0:
|
134
184
|
raise HTTPException(status_code=401, detail="Permission denied, credential required")
|
185
|
+
if thumb:
|
186
|
+
return await emit_thumbnail(path, download, create_time=None)
|
187
|
+
|
135
188
|
if path == "/":
|
136
189
|
if flat:
|
137
190
|
raise HTTPException(status_code=400, detail="Flat query not supported for root path")
|
@@ -146,6 +199,7 @@ async def get_file(path: str, download: bool = False, flat: bool = False, user:
|
|
146
199
|
|
147
200
|
return await fconn.list_path(path, flat = flat)
|
148
201
|
|
202
|
+
# handle file query
|
149
203
|
async with unique_cursor() as conn:
|
150
204
|
fconn = FileConn(conn)
|
151
205
|
file_record = await fconn.get_file_record(path)
|
@@ -160,32 +214,13 @@ async def get_file(path: str, download: bool = False, flat: bool = False, user:
|
|
160
214
|
if not allow_access:
|
161
215
|
raise HTTPException(status_code=403, detail=reason)
|
162
216
|
|
163
|
-
|
164
|
-
|
165
|
-
if media_type is None:
|
166
|
-
media_type = file_record.mime_type
|
167
|
-
if not file_record.external:
|
168
|
-
fblob = await db.read_file(path)
|
169
|
-
return Response(
|
170
|
-
content=fblob, media_type=media_type, headers={
|
171
|
-
"Content-Disposition": f"{disposition}; filename={fname}",
|
172
|
-
"Content-Length": str(len(fblob)),
|
173
|
-
"Last-Modified": format_last_modified(file_record.create_time)
|
174
|
-
}
|
175
|
-
)
|
176
|
-
else:
|
177
|
-
return StreamingResponse(
|
178
|
-
await db.read_file_stream(path), media_type=media_type, headers={
|
179
|
-
"Content-Disposition": f"{disposition}; filename={fname}",
|
180
|
-
"Content-Length": str(file_record.file_size),
|
181
|
-
"Last-Modified": format_last_modified(file_record.create_time)
|
182
|
-
}
|
183
|
-
)
|
184
|
-
|
185
|
-
if download:
|
186
|
-
return await send('application/octet-stream', "attachment")
|
217
|
+
if thumb:
|
218
|
+
return await emit_thumbnail(path, download, create_time=file_record.create_time)
|
187
219
|
else:
|
188
|
-
|
220
|
+
if download:
|
221
|
+
return await emit_file(file_record, 'application/octet-stream', "attachment")
|
222
|
+
else:
|
223
|
+
return await emit_file(file_record, None, "inline")
|
189
224
|
|
190
225
|
@router_fs.put("/{path:path}")
|
191
226
|
@handle_exception
|
lfss/src/stat.py
CHANGED
@@ -1,7 +1,8 @@
|
|
1
1
|
from typing import Optional, Any
|
2
2
|
import aiosqlite
|
3
|
+
from contextlib import asynccontextmanager
|
3
4
|
from .config import DATA_HOME
|
4
|
-
from .utils import debounce_async
|
5
|
+
from .utils import debounce_async
|
5
6
|
|
6
7
|
class RequestDB:
|
7
8
|
conn: aiosqlite.Connection
|
@@ -26,6 +27,14 @@ class RequestDB:
|
|
26
27
|
)
|
27
28
|
''')
|
28
29
|
return self
|
30
|
+
|
31
|
+
def connect(self):
|
32
|
+
@asynccontextmanager
|
33
|
+
async def _mgr():
|
34
|
+
await self.init()
|
35
|
+
yield self
|
36
|
+
await self.close()
|
37
|
+
return _mgr()
|
29
38
|
|
30
39
|
async def close(self):
|
31
40
|
await self.conn.close()
|
@@ -65,7 +74,6 @@ class RequestDB:
|
|
65
74
|
assert cursor.lastrowid is not None
|
66
75
|
return cursor.lastrowid
|
67
76
|
|
68
|
-
@concurrent_wrap()
|
69
77
|
async def shrink(self, max_rows: int = 1_000_000, time_before: float = 0):
|
70
78
|
async with aiosqlite.connect(self.db) as conn:
|
71
79
|
|
lfss/src/thumb.py
ADDED
@@ -0,0 +1,121 @@
|
|
1
|
+
from lfss.src.config import DATA_HOME
|
2
|
+
from lfss.src.database import FileConn
|
3
|
+
from lfss.src.connection_pool import unique_cursor
|
4
|
+
from typing import Optional
|
5
|
+
from PIL import Image
|
6
|
+
from io import BytesIO
|
7
|
+
import aiosqlite
|
8
|
+
|
9
|
+
def prepare_svg(svg_str: str):
|
10
|
+
"""
|
11
|
+
Injects grey color to the svg string, and encodes it to bytes
|
12
|
+
"""
|
13
|
+
color = "#666"
|
14
|
+
x = svg_str.replace('<path', '<path fill="{}"'.format(color))
|
15
|
+
x = x.replace('<svg', '<svg fill="{}"'.format(color))
|
16
|
+
return x.encode()
|
17
|
+
|
18
|
+
# from: https://pictogrammers.com/library/mdi/
|
19
|
+
ICON_FOLER = prepare_svg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>folder-outline</title><path d="M20,18H4V8H20M20,6H12L10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6Z" /></svg>')
|
20
|
+
ICON_FILE = prepare_svg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>file-document-outline</title><path d="M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z" /></svg>')
|
21
|
+
ICON_PDF = prepare_svg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>file-pdf-box</title><path d="M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9.5 11.5C9.5 12.3 8.8 13 8 13H7V15H5.5V9H8C8.8 9 9.5 9.7 9.5 10.5V11.5M14.5 13.5C14.5 14.3 13.8 15 13 15H10.5V9H13C13.8 9 14.5 9.7 14.5 10.5V13.5M18.5 10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5M12 10.5H13V13.5H12V10.5M7 10.5H8V11.5H7V10.5Z" /></svg>')
|
22
|
+
ICON_EXE = prepare_svg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>application-brackets-outline</title><path d="M9.5,8.5L11,10L8,13L11,16L9.5,17.5L5,13L9.5,8.5M14.5,17.5L13,16L16,13L13,10L14.5,8.5L19,13L14.5,17.5M21,2H3A2,2 0 0,0 1,4V20A2,2 0 0,0 3,22H21A2,2 0 0,0 23,20V4A2,2 0 0,0 21,2M21,20H3V6H21V20Z" /></svg>')
|
23
|
+
ICON_ZIP = prepare_svg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>zip-box-outline</title><path d="M12 17V15H14V17H12M14 13V11H12V13H14M14 9V7H12V9H14M10 11H12V9H10V11M10 15H12V13H10V15M21 5V19C21 20.1 20.1 21 19 21H5C3.9 21 3 20.1 3 19V5C3 3.9 3.9 3 5 3H19C20.1 3 21 3.9 21 5M19 5H12V7H10V5H5V19H19V5Z" /></svg>')
|
24
|
+
ICON_CODE = prepare_svg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>file-code-outline</title><path d="M14 2H6C4.89 2 4 2.9 4 4V20C4 21.11 4.89 22 6 22H18C19.11 22 20 21.11 20 20V8L14 2M18 20H6V4H13V9H18V20M9.54 15.65L11.63 17.74L10.35 19L7 15.65L10.35 12.3L11.63 13.56L9.54 15.65M17 15.65L13.65 19L12.38 17.74L14.47 15.65L12.38 13.56L13.65 12.3L17 15.65Z" /></svg>')
|
25
|
+
|
26
|
+
THUMB_DB = DATA_HOME / 'thumbs.db'
|
27
|
+
THUMB_SIZE = (48, 48)
|
28
|
+
|
29
|
+
async def _maybe_init_thumb(c: aiosqlite.Cursor):
|
30
|
+
await c.execute('''
|
31
|
+
CREATE TABLE IF NOT EXISTS thumbs (
|
32
|
+
path TEXT PRIMARY KEY,
|
33
|
+
ctime TEXT,
|
34
|
+
thumb BLOB
|
35
|
+
)
|
36
|
+
''')
|
37
|
+
|
38
|
+
async def _get_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str) -> Optional[bytes]:
|
39
|
+
res = await c.execute('''
|
40
|
+
SELECT ctime, thumb FROM thumbs WHERE path = ?
|
41
|
+
''', (path, ))
|
42
|
+
row = await res.fetchone()
|
43
|
+
if row is None:
|
44
|
+
return None
|
45
|
+
# check if ctime matches, if not delete and return None
|
46
|
+
if row[0] != ctime:
|
47
|
+
await _delete_cache_thumb(c, path)
|
48
|
+
return None
|
49
|
+
blob: bytes = row[1]
|
50
|
+
return blob
|
51
|
+
|
52
|
+
async def _save_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str, raw_bytes: bytes) -> bytes:
|
53
|
+
raw_img = Image.open(BytesIO(raw_bytes))
|
54
|
+
raw_img.thumbnail(THUMB_SIZE)
|
55
|
+
img = raw_img.convert('RGB')
|
56
|
+
bio = BytesIO()
|
57
|
+
img.save(bio, 'JPEG')
|
58
|
+
blob = bio.getvalue()
|
59
|
+
await c.execute('''
|
60
|
+
INSERT OR REPLACE INTO thumbs (path, ctime, thumb) VALUES (?, ?, ?)
|
61
|
+
''', (path, ctime, blob))
|
62
|
+
await c.execute('COMMIT') # commit immediately
|
63
|
+
return blob
|
64
|
+
|
65
|
+
async def _delete_cache_thumb(c: aiosqlite.Cursor, path: str):
|
66
|
+
await c.execute('''
|
67
|
+
DELETE FROM thumbs WHERE path = ?
|
68
|
+
''', (path, ))
|
69
|
+
await c.execute('COMMIT')
|
70
|
+
|
71
|
+
async def get_thumb(path: str) -> tuple[bytes, str]:
|
72
|
+
"""
|
73
|
+
returns [image bytes of thumbnail, mime type] if supported,
|
74
|
+
or None if not supported.
|
75
|
+
Raises FileNotFoundError if file does not exist
|
76
|
+
"""
|
77
|
+
if path.endswith('/'):
|
78
|
+
return ICON_FOLER, 'image/svg+xml'
|
79
|
+
|
80
|
+
async with aiosqlite.connect(THUMB_DB) as conn:
|
81
|
+
cur = await conn.cursor()
|
82
|
+
await _maybe_init_thumb(cur)
|
83
|
+
|
84
|
+
async with unique_cursor() as main_c:
|
85
|
+
fconn = FileConn(main_c)
|
86
|
+
r = await fconn.get_file_record(path)
|
87
|
+
if r is None:
|
88
|
+
await _delete_cache_thumb(cur, path)
|
89
|
+
raise FileNotFoundError(f'File not found: {path}')
|
90
|
+
|
91
|
+
if not r.mime_type.startswith('image/'):
|
92
|
+
match r.mime_type:
|
93
|
+
case 'application/pdf' | 'application/x-pdf':
|
94
|
+
return ICON_PDF, 'image/svg+xml'
|
95
|
+
case 'application/x-msdownload' | 'application/x-msdos-program' | 'application/x-msi' | 'application/octet-stream':
|
96
|
+
return ICON_EXE, 'image/svg+xml'
|
97
|
+
case 'application/zip' | 'application/x-zip-compressed' | 'application/x-zip' | 'application/x-compressed' | 'application/x-compress':
|
98
|
+
return ICON_ZIP, 'image/svg+xml'
|
99
|
+
case 'text/plain' | 'text/x-python' | 'text/x-c' | 'text/x-c++' | 'text/x-java' | 'text/x-php' | 'text/x-shellscript' | 'text/x-perl' | 'text/x-ruby' | 'text/x-go' | 'text/x-rust' | 'text/x-haskell' | 'text/x-lisp' | 'text/x-lua' | 'text/x-tcl' | 'text/x-sql' | 'text/x-yaml' | 'text/x-xml' | 'text/x-markdown' | 'text/x-tex' | 'text/x-asm' | 'text/x-fortran' | 'text/x-pascal' | 'text/x-erlang' | 'text/x-ocaml' | 'text/x-matlab' | 'text/x-csharp' | 'text/x-swift' | 'text/x-kotlin' | 'text/x-dart' | 'text/x-julia' | 'text/x-scala' | 'text/x-clojure' | 'text/x-elm' | 'text/x-crystal' | 'text/x-nim' | 'text/x-zig':
|
100
|
+
return ICON_CODE, 'image/svg+xml'
|
101
|
+
case _:
|
102
|
+
return ICON_FILE, 'image/svg+xml'
|
103
|
+
|
104
|
+
c_time = r.create_time
|
105
|
+
thumb_blob = await _get_cache_thumb(cur, path, c_time)
|
106
|
+
if thumb_blob is not None:
|
107
|
+
return thumb_blob, "image/jpeg"
|
108
|
+
|
109
|
+
# generate thumb
|
110
|
+
async with unique_cursor() as main_c:
|
111
|
+
fconn = FileConn(main_c)
|
112
|
+
if r.external:
|
113
|
+
data = b""
|
114
|
+
async for chunk in fconn.get_file_blob_external(r.file_id):
|
115
|
+
data += chunk
|
116
|
+
else:
|
117
|
+
data = await fconn.get_file_blob(r.file_id)
|
118
|
+
assert data is not None
|
119
|
+
|
120
|
+
thumb_blob = await _save_cache_thumb(cur, path, c_time, data)
|
121
|
+
return thumb_blob, "image/jpeg"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.7.
|
3
|
+
Version: 0.7.12
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li, mengxun
|
@@ -15,6 +15,8 @@ Requires-Dist: aiofiles (==23.*)
|
|
15
15
|
Requires-Dist: aiosqlite (==0.*)
|
16
16
|
Requires-Dist: fastapi (==0.*)
|
17
17
|
Requires-Dist: mimesniff (==1.*)
|
18
|
+
Requires-Dist: pillow
|
19
|
+
Requires-Dist: uvicorn (==0.*)
|
18
20
|
Project-URL: Repository, https://github.com/MenxLi/lfss
|
19
21
|
Description-Content-Type: text/markdown
|
20
22
|
|
@@ -7,30 +7,32 @@ frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
|
|
7
7
|
frontend/info.js,sha256=WhOGaeqMoezEAfg4nIpK26hvejC7AZ-ZDLiJmRj0kDk,5758
|
8
8
|
frontend/popup.css,sha256=TJZYFW1ZcdD1IVTlNPYNtMWKPbN6XDbQ4hKBOFK8uLg,1284
|
9
9
|
frontend/popup.js,sha256=3PgaGZmxSdV1E-D_MWgcR7aHWkcsHA1BNKSOkmP66tA,5191
|
10
|
-
frontend/scripts.js,sha256=
|
11
|
-
frontend/styles.css,sha256=
|
12
|
-
frontend/utils.js,sha256=
|
10
|
+
frontend/scripts.js,sha256=4LHc1M2QwxtCJsbShxXu0uKYBY8auv84JuYcd1zwpQM,21069
|
11
|
+
frontend/styles.css,sha256=ghtQLSi7-zS8Yuac49TNiGVxNgggh-R3SM7HDYQM97c,4482
|
12
|
+
frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
|
13
13
|
lfss/cli/balance.py,sha256=R2rbO2tg9TVnnQIVeU0GJVeMS-5LDhEdk4mbOE9qGq0,4121
|
14
14
|
lfss/cli/cli.py,sha256=LH1nx5wI1K2DZ3hvHz7oq5HcXVDoW2V6sr7q9gJ8gqo,4621
|
15
15
|
lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
|
16
|
-
lfss/cli/serve.py,sha256=
|
16
|
+
lfss/cli/serve.py,sha256=T-jz_PJcaY9FJRRfyWV9MbjDI73YdOOCn4Nr2PO-s0c,993
|
17
17
|
lfss/cli/user.py,sha256=ETLtj0N-kmxv0mhmeAsO6cY7kPq7nOOP4DetxIRoQpQ,3405
|
18
|
-
lfss/cli/vacuum.py,sha256=
|
19
|
-
lfss/client/__init__.py,sha256=
|
20
|
-
lfss/client/api.py,sha256=
|
18
|
+
lfss/cli/vacuum.py,sha256=4TMMYC_5yEt7jeaFTVC3iX0X6aUzYXBiCsrcIYgw_uA,3695
|
19
|
+
lfss/client/__init__.py,sha256=kcClDdHaQowAhHFYdrFyFqHIOe5MEjfENYq1icuj3Mg,4577
|
20
|
+
lfss/client/api.py,sha256=aHorSuxl79xvRRfwuitXANYoTogWKVO11XX7mdrhHTE,5807
|
21
21
|
lfss/sql/init.sql,sha256=C-JtQAlaOjESI8uoF1Y_9dKukEVSw5Ll-7yA3gG-XHU,1210
|
22
22
|
lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
|
23
23
|
lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
24
|
+
lfss/src/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
|
24
25
|
lfss/src/config.py,sha256=2qN2nu3onP1M7U6ENC0ZbVVRE90aDomTcQ1GsBLLHF8,800
|
25
26
|
lfss/src/connection_pool.py,sha256=r4Ho5d_Gd4S_KbT7515UJoiyfIgS6xyttqMsKqOfaIg,5190
|
26
27
|
lfss/src/database.py,sha256=w2QPE3h1Lx0D0fUmdtu9s1XHpNp9p27zqm8AVeP2UVg,32476
|
27
28
|
lfss/src/datatype.py,sha256=WfrLALU_7wei5-i_b0TxY8xWI5mwxLUHFepHSps49zA,1767
|
28
29
|
lfss/src/error.py,sha256=imbhwnbhnI3HLhkbfICROe3F0gleKrOk4XnqHJDOtuI,285
|
29
30
|
lfss/src/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
|
30
|
-
lfss/src/server.py,sha256=
|
31
|
-
lfss/src/stat.py,sha256=
|
31
|
+
lfss/src/server.py,sha256=hFUeg14-G2nIfY2sD9fL_3YvRv7TuuGQk932HCT_NPs,17224
|
32
|
+
lfss/src/stat.py,sha256=Wr-ug_JqtbSIf3XwQnv1xheGhsDTEOlLWuYoKO_26Jo,3201
|
33
|
+
lfss/src/thumb.py,sha256=KnKvjP3RwhjqFZxZabdUTuk5l9fv3AXO2YUgu0Xzvps,6770
|
32
34
|
lfss/src/utils.py,sha256=TBGYvgt6xMP8UC5wTGHAr9fmdhu0_gjOtxcSeyvGyVM,3918
|
33
|
-
lfss-0.7.
|
34
|
-
lfss-0.7.
|
35
|
-
lfss-0.7.
|
36
|
-
lfss-0.7.
|
35
|
+
lfss-0.7.12.dist-info/METADATA,sha256=bFSR41dbc7Y_Ui4ALbYEqbCjvmpvh4WNBmonJ3Pn868,2021
|
36
|
+
lfss-0.7.12.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
37
|
+
lfss-0.7.12.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
|
38
|
+
lfss-0.7.12.dist-info/RECORD,,
|
File without changes
|
File without changes
|