lfss 0.7.11__tar.gz → 0.7.13__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.7.11 → lfss-0.7.13}/PKG-INFO +2 -1
- {lfss-0.7.11 → lfss-0.7.13}/frontend/index.html +21 -3
- {lfss-0.7.11 → lfss-0.7.13}/frontend/scripts.js +53 -3
- {lfss-0.7.11 → lfss-0.7.13}/frontend/styles.css +15 -0
- lfss-0.7.13/frontend/thumb.css +16 -0
- lfss-0.7.13/frontend/thumb.js +68 -0
- {lfss-0.7.11 → lfss-0.7.13}/frontend/utils.js +7 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/client/__init__.py +3 -3
- {lfss-0.7.11 → lfss-0.7.13}/lfss/client/api.py +3 -2
- lfss-0.7.13/lfss/src/bounded_pool.py +44 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/config.py +3 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/server.py +65 -27
- lfss-0.7.13/lfss/src/thumb.py +91 -0
- {lfss-0.7.11 → lfss-0.7.13}/pyproject.toml +2 -1
- {lfss-0.7.11 → lfss-0.7.13}/Readme.md +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/docs/Known_issues.md +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/docs/Permission.md +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/frontend/api.js +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/frontend/info.css +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/frontend/info.js +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/frontend/popup.css +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/frontend/popup.js +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/balance.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/cli.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/panel.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/serve.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/user.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/vacuum.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/sql/init.sql +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/sql/pragma.sql +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/__init__.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/connection_pool.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/database.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/datatype.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/error.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/log.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/stat.py +0 -0
- {lfss-0.7.11 → lfss-0.7.13}/lfss/src/utils.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.7.
|
3
|
+
Version: 0.7.13
|
4
4
|
Summary: Lightweight file storage service
|
5
5
|
Home-page: https://github.com/MenxLi/lfss
|
6
6
|
Author: li, mengxun
|
@@ -15,6 +15,7 @@ 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
|
18
19
|
Requires-Dist: uvicorn (==0.*)
|
19
20
|
Project-URL: Repository, https://github.com/MenxLi/lfss
|
20
21
|
Description-Content-Type: text/markdown
|
@@ -24,9 +24,27 @@
|
|
24
24
|
</div>
|
25
25
|
|
26
26
|
<div class="container content" id="view">
|
27
|
-
<div id="
|
28
|
-
<
|
29
|
-
|
27
|
+
<div id="top-bar">
|
28
|
+
<div id="position-hint" class='disconnected'>
|
29
|
+
<span></span>
|
30
|
+
<label></label>
|
31
|
+
</div>
|
32
|
+
<div>
|
33
|
+
<span style="margin-right: 0.25rem; color: #999;">
|
34
|
+
Sort by</span>
|
35
|
+
<select id="sort-by-sel">
|
36
|
+
<option value="none">None</option>
|
37
|
+
<option value="name">Name</option>
|
38
|
+
<option value="size">Size</option>
|
39
|
+
<option value="access">Accessed</option>
|
40
|
+
<option value="create">Created</option>
|
41
|
+
<option value="mime">Type</option>
|
42
|
+
</select>
|
43
|
+
<select id="sort-order-sel">
|
44
|
+
<option value="asc">Ascending</option>
|
45
|
+
<option value="desc">Descending</option>
|
46
|
+
</select>
|
47
|
+
</div>
|
30
48
|
</div>
|
31
49
|
<table id="files">
|
32
50
|
<thead>
|
@@ -1,8 +1,9 @@
|
|
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
|
+
import { makeThumbHtml } from './thumb.js';
|
6
7
|
|
7
8
|
const conn = new Connector();
|
8
9
|
let userRecord = null;
|
@@ -22,6 +23,8 @@ const uploadFileSelector = document.querySelector('#file-selector');
|
|
22
23
|
const uploadFileNameInput = document.querySelector('#file-name');
|
23
24
|
const uploadButton = document.querySelector('#upload-btn');
|
24
25
|
const randomizeFnameButton = document.querySelector('#randomize-fname-btn');
|
26
|
+
const sortBySelect = document.querySelector('#sort-by-sel');
|
27
|
+
const sortOrderSelect = document.querySelector('#sort-order-sel');
|
25
28
|
|
26
29
|
conn.config.endpoint = endpointInput.value;
|
27
30
|
conn.config.token = tokenInput.value;
|
@@ -219,6 +222,40 @@ function maybeRefreshFileList(){
|
|
219
222
|
}
|
220
223
|
}
|
221
224
|
|
225
|
+
let sortBy = sortBySelect.value;
|
226
|
+
let sortOrder = sortOrderSelect.value;
|
227
|
+
/** @param {import('./api.js').DirectoryRecord} dirs */
|
228
|
+
function sortDirList(dirs){
|
229
|
+
if (sortBy === 'name'){
|
230
|
+
dirs.sort((a, b) => { return a.url.localeCompare(b.url); });
|
231
|
+
}
|
232
|
+
if (sortOrder === 'desc'){ dirs.reverse(); }
|
233
|
+
}
|
234
|
+
/** @param {import('./api.js').FileRecord} files */
|
235
|
+
function sortFileList(files){
|
236
|
+
function timestr2num(timestr){
|
237
|
+
return new Date(timestr).getTime();
|
238
|
+
}
|
239
|
+
if (sortBy === 'name'){
|
240
|
+
files.sort((a, b) => { return a.url.localeCompare(b.url); });
|
241
|
+
}
|
242
|
+
if (sortBy === 'size'){
|
243
|
+
files.sort((a, b) => { return a.file_size - b.file_size; });
|
244
|
+
}
|
245
|
+
if (sortBy === 'access'){
|
246
|
+
files.sort((a, b) => { return timestr2num(a.access_time) - timestr2num(b.access_time); });
|
247
|
+
}
|
248
|
+
if (sortBy === 'create'){
|
249
|
+
files.sort((a, b) => { return timestr2num(a.create_time) - timestr2num(b.create_time); });
|
250
|
+
}
|
251
|
+
if (sortBy === 'mime'){
|
252
|
+
files.sort((a, b) => { return a.mime_type.localeCompare(b.mime_type); });
|
253
|
+
}
|
254
|
+
if (sortOrder === 'desc'){ files.reverse(); }
|
255
|
+
}
|
256
|
+
sortBySelect.addEventListener('change', (elem) => {sortBy = elem.target.value; refreshFileList();});
|
257
|
+
sortOrderSelect.addEventListener('change', (elem) => {sortOrder = elem.target.value; refreshFileList();});
|
258
|
+
|
222
259
|
function refreshFileList(){
|
223
260
|
conn.listPath(pathInput.value)
|
224
261
|
.then(data => {
|
@@ -232,6 +269,9 @@ function refreshFileList(){
|
|
232
269
|
if (!data.dirs){ data.dirs = []; }
|
233
270
|
if (!data.files){ data.files = []; }
|
234
271
|
|
272
|
+
sortDirList(data.dirs);
|
273
|
+
sortFileList(data.files);
|
274
|
+
|
235
275
|
data.dirs.forEach(dir => {
|
236
276
|
const tr = document.createElement('tr');
|
237
277
|
const sizeTd = document.createElement('td');
|
@@ -251,7 +291,11 @@ function refreshFileList(){
|
|
251
291
|
onPathChange();
|
252
292
|
});
|
253
293
|
dirLink.href = '#';
|
254
|
-
|
294
|
+
const nameDiv = document.createElement('div');
|
295
|
+
nameDiv.classList.add('filename-container');
|
296
|
+
nameDiv.innerHTML = makeThumbHtml(conn, dir);
|
297
|
+
nameDiv.appendChild(dirLink);
|
298
|
+
nameTd.appendChild(nameDiv);
|
255
299
|
|
256
300
|
tr.appendChild(nameTd);
|
257
301
|
tbody.appendChild(tr);
|
@@ -343,7 +387,13 @@ function refreshFileList(){
|
|
343
387
|
const nameTd = document.createElement('td');
|
344
388
|
const plainUrl = decodePathURI(file.url);
|
345
389
|
const fileName = plainUrl.split('/').pop();
|
346
|
-
|
390
|
+
|
391
|
+
nameTd.innerHTML = `
|
392
|
+
<div class="filename-container">
|
393
|
+
${makeThumbHtml(conn, file)}
|
394
|
+
<span>${asHtmlText(fileName)}</span>
|
395
|
+
</div>
|
396
|
+
`
|
347
397
|
tr.appendChild(nameTd);
|
348
398
|
tbody.appendChild(tr);
|
349
399
|
}
|
@@ -1,5 +1,6 @@
|
|
1
1
|
@import "./popup.css";
|
2
2
|
@import "./info.css";
|
3
|
+
@import "./thumb.css";
|
3
4
|
|
4
5
|
body{
|
5
6
|
font-family: Arial, sans-serif;
|
@@ -120,6 +121,13 @@ input#path{
|
|
120
121
|
cursor: pointer;
|
121
122
|
}
|
122
123
|
|
124
|
+
div#top-bar{
|
125
|
+
display: flex;
|
126
|
+
flex-direction: row;
|
127
|
+
justify-content: space-between;
|
128
|
+
align-items: center;
|
129
|
+
gap: 1rem;
|
130
|
+
}
|
123
131
|
div#position-hint{
|
124
132
|
color: rgb(138, 138, 138);
|
125
133
|
border-radius: 0.5rem;
|
@@ -181,6 +189,13 @@ table#files tr td:nth-child(3), table#files tr td:nth-child(5){
|
|
181
189
|
width: 12%;
|
182
190
|
}
|
183
191
|
|
192
|
+
div.filename-container{
|
193
|
+
display: flex;
|
194
|
+
flex-direction: row;
|
195
|
+
align-items: center;
|
196
|
+
gap: 0.5rem;
|
197
|
+
height: 32px;
|
198
|
+
}
|
184
199
|
label#upload-file-prefix{
|
185
200
|
width: 800px;
|
186
201
|
text-align: right;
|
@@ -0,0 +1,16 @@
|
|
1
|
+
|
2
|
+
div.thumb{
|
3
|
+
height: 32px;
|
4
|
+
width: 32px;
|
5
|
+
border-radius: 0.25rem;
|
6
|
+
display: contents;
|
7
|
+
}
|
8
|
+
img.thumb{
|
9
|
+
max-height: 32px; /* smaller than backend */
|
10
|
+
max-width: 32px;
|
11
|
+
border-radius: 0.25rem;
|
12
|
+
}
|
13
|
+
img.thumb-svg{
|
14
|
+
/* dark blue */
|
15
|
+
filter: invert(30%) sepia(100%) saturate(80%) hue-rotate(180deg);
|
16
|
+
}
|
@@ -0,0 +1,68 @@
|
|
1
|
+
|
2
|
+
/**
|
3
|
+
* @import { FileRecord, DirectoryRecord } from './api.js'
|
4
|
+
*/
|
5
|
+
import Connector from './api.js';
|
6
|
+
|
7
|
+
// from: https://pictogrammers.com/library/mdi/
|
8
|
+
const ICON_FOLDER = '<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>';
|
9
|
+
const ICON_FILE = '<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>'
|
10
|
+
const ICON_PDF = '<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>'
|
11
|
+
const ICON_EXE = '<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>'
|
12
|
+
const ICON_ZIP = '<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>'
|
13
|
+
const ICON_CODE = '<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>'
|
14
|
+
const ICON_VIDEO = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>television-play</title><path d="M21,3H3C1.89,3 1,3.89 1,5V17A2,2 0 0,0 3,19H8V21H16V19H21A2,2 0 0,0 23,17V5C23,3.89 22.1,3 21,3M21,17H3V5H21M16,11L9,15V7" /></svg>'
|
15
|
+
const ICON_IMAGE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>image-outline</title><path d="M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z" /></svg>'
|
16
|
+
|
17
|
+
function getIconSVGFromMimeType(mimeType){
|
18
|
+
if (mimeType == 'directory'){
|
19
|
+
return ICON_FOLDER;
|
20
|
+
}
|
21
|
+
if (mimeType.startsWith('video/')){
|
22
|
+
return ICON_VIDEO;
|
23
|
+
}
|
24
|
+
if (mimeType.startsWith('image/')){
|
25
|
+
return ICON_IMAGE;
|
26
|
+
}
|
27
|
+
switch (mimeType){
|
28
|
+
case
|
29
|
+
'application/pdf' || 'application/x-pdf':
|
30
|
+
return ICON_PDF;
|
31
|
+
case
|
32
|
+
'application/x-msdownload' || 'application/x-msdos-program' || 'application/x-msi' || 'application/x-ms-wim' || 'application/octet-stream' || 'application/x-apple-diskimage':
|
33
|
+
return ICON_EXE;
|
34
|
+
case
|
35
|
+
'application/zip' || 'application/x-zip-compressed' || 'application/x-7z-compressed' || 'application/x-rar-compressed' || 'application/x-tar' || 'application/x-gzip':
|
36
|
+
return ICON_ZIP;
|
37
|
+
case
|
38
|
+
'text/x-python' || 'text/x-c' || 'text/x-c++' || 'text/x-java' || 'text/x-javascript' || 'text/x-php' || 'text/x-rust' || 'text/x-go' || 'text/x-csharp' || 'text/x-typescript' ||
|
39
|
+
'text/x-html' || 'text/x-css' || 'text/x-sql' || 'text/x-xml' || 'text/x-yaml' || 'text/x-json' || 'text/x-markdown' || 'text/x-shellscript' ||
|
40
|
+
'text/x-bat' || 'text/x-powershell' || 'text/x-bash' || 'text/x-perl' || 'text/x-ruby' || 'text/x-lua' || 'text/x-tcl' || 'text/x-lisp' ||
|
41
|
+
'text/x-haskell' || 'text/x-elm' || 'text/x-crystal' || 'text/x-nim' || 'text/x-zig':
|
42
|
+
return ICON_CODE;
|
43
|
+
default:
|
44
|
+
return ICON_FILE;
|
45
|
+
}
|
46
|
+
}
|
47
|
+
|
48
|
+
function getSafeIconUrl(icon_str){
|
49
|
+
return 'data:image/svg+xml,' + encodeURIComponent(icon_str);
|
50
|
+
}
|
51
|
+
|
52
|
+
let thumb_counter = 0;
|
53
|
+
/**
|
54
|
+
* @param {Connector} c
|
55
|
+
* @param {FileRecord | DirectoryRecord} r
|
56
|
+
* @returns {string}
|
57
|
+
*/
|
58
|
+
export function makeThumbHtml(c, r){
|
59
|
+
const token = c.config.token;
|
60
|
+
const mtype = r.mime_type? r.mime_type : 'directory';
|
61
|
+
const thumb_id = `thumb-${thumb_counter++}`;
|
62
|
+
return `
|
63
|
+
<div class="thumb" id="${thumb_id}"> \
|
64
|
+
<img src="${c.config.endpoint}/${r.url}?token=${token}&thumb=true" alt="${r.url}" class="thumb" \
|
65
|
+
onerror="this.src='${getSafeIconUrl(getIconSVGFromMimeType(mtype))}';this.classList.add('thumb-svg');" \
|
66
|
+
</div>
|
67
|
+
`;
|
68
|
+
}
|
@@ -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
|
}
|
@@ -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
|
@@ -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
|
|
@@ -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
|
+
|
@@ -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")
|
@@ -49,7 +50,7 @@ def handle_exception(fn):
|
|
49
50
|
if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
|
50
51
|
if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
|
51
52
|
logger.error(f"Uncaptured error in {fn.__name__}: {e}")
|
52
|
-
raise
|
53
|
+
raise
|
53
54
|
return wrapper
|
54
55
|
|
55
56
|
async def get_credential_from_params(request: Request):
|
@@ -118,9 +119,61 @@ async def log_requests(request: Request, call_next):
|
|
118
119
|
|
119
120
|
router_fs = APIRouter(prefix="")
|
120
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
|
+
if (thumb_res := await get_thumb(path)) is None:
|
131
|
+
raise HTTPException(status_code=415, detail="Thumbnail not supported")
|
132
|
+
thumb_blob, mime_type = thumb_res
|
133
|
+
disp = "inline" if not download else "attachment"
|
134
|
+
headers = {
|
135
|
+
"Content-Disposition": f"{disp}; filename={fname}.thumb.jpg",
|
136
|
+
"Content-Length": str(len(thumb_blob)),
|
137
|
+
}
|
138
|
+
if create_time is not None:
|
139
|
+
headers["Last-Modified"] = format_last_modified(create_time)
|
140
|
+
return Response(
|
141
|
+
content=thumb_blob, media_type=mime_type, headers=headers
|
142
|
+
)
|
143
|
+
async def emit_file(
|
144
|
+
file_record: FileRecord,
|
145
|
+
media_type: Optional[str] = None,
|
146
|
+
disposition = "attachment"
|
147
|
+
):
|
148
|
+
if media_type is None:
|
149
|
+
media_type = file_record.mime_type
|
150
|
+
path = file_record.url
|
151
|
+
fname = path.split("/")[-1]
|
152
|
+
if not file_record.external:
|
153
|
+
fblob = await db.read_file(path)
|
154
|
+
return Response(
|
155
|
+
content=fblob, media_type=media_type, headers={
|
156
|
+
"Content-Disposition": f"{disposition}; filename={fname}",
|
157
|
+
"Content-Length": str(len(fblob)),
|
158
|
+
"Last-Modified": format_last_modified(file_record.create_time)
|
159
|
+
}
|
160
|
+
)
|
161
|
+
else:
|
162
|
+
return StreamingResponse(
|
163
|
+
await db.read_file_stream(path), media_type=media_type, headers={
|
164
|
+
"Content-Disposition": f"{disposition}; filename={fname}",
|
165
|
+
"Content-Length": str(file_record.file_size),
|
166
|
+
"Last-Modified": format_last_modified(file_record.create_time)
|
167
|
+
}
|
168
|
+
)
|
169
|
+
|
121
170
|
@router_fs.get("/{path:path}")
|
122
171
|
@handle_exception
|
123
|
-
async def get_file(
|
172
|
+
async def get_file(
|
173
|
+
path: str,
|
174
|
+
download: bool = False, flat: bool = False, thumb: bool = False,
|
175
|
+
user: UserRecord = Depends(get_current_user)
|
176
|
+
):
|
124
177
|
path = ensure_uri_compnents(path)
|
125
178
|
|
126
179
|
# handle directory query
|
@@ -131,6 +184,9 @@ async def get_file(path: str, download: bool = False, flat: bool = False, user:
|
|
131
184
|
fconn = FileConn(conn)
|
132
185
|
if user.id == 0:
|
133
186
|
raise HTTPException(status_code=401, detail="Permission denied, credential required")
|
187
|
+
if thumb:
|
188
|
+
return await emit_thumbnail(path, download, create_time=None)
|
189
|
+
|
134
190
|
if path == "/":
|
135
191
|
if flat:
|
136
192
|
raise HTTPException(status_code=400, detail="Flat query not supported for root path")
|
@@ -145,6 +201,7 @@ async def get_file(path: str, download: bool = False, flat: bool = False, user:
|
|
145
201
|
|
146
202
|
return await fconn.list_path(path, flat = flat)
|
147
203
|
|
204
|
+
# handle file query
|
148
205
|
async with unique_cursor() as conn:
|
149
206
|
fconn = FileConn(conn)
|
150
207
|
file_record = await fconn.get_file_record(path)
|
@@ -159,32 +216,13 @@ async def get_file(path: str, download: bool = False, flat: bool = False, user:
|
|
159
216
|
if not allow_access:
|
160
217
|
raise HTTPException(status_code=403, detail=reason)
|
161
218
|
|
162
|
-
|
163
|
-
|
164
|
-
if media_type is None:
|
165
|
-
media_type = file_record.mime_type
|
166
|
-
if not file_record.external:
|
167
|
-
fblob = await db.read_file(path)
|
168
|
-
return Response(
|
169
|
-
content=fblob, media_type=media_type, headers={
|
170
|
-
"Content-Disposition": f"{disposition}; filename={fname}",
|
171
|
-
"Content-Length": str(len(fblob)),
|
172
|
-
"Last-Modified": format_last_modified(file_record.create_time)
|
173
|
-
}
|
174
|
-
)
|
175
|
-
else:
|
176
|
-
return StreamingResponse(
|
177
|
-
await db.read_file_stream(path), media_type=media_type, headers={
|
178
|
-
"Content-Disposition": f"{disposition}; filename={fname}",
|
179
|
-
"Content-Length": str(file_record.file_size),
|
180
|
-
"Last-Modified": format_last_modified(file_record.create_time)
|
181
|
-
}
|
182
|
-
)
|
183
|
-
|
184
|
-
if download:
|
185
|
-
return await send('application/octet-stream', "attachment")
|
219
|
+
if thumb:
|
220
|
+
return await emit_thumbnail(path, download, create_time=file_record.create_time)
|
186
221
|
else:
|
187
|
-
|
222
|
+
if download:
|
223
|
+
return await emit_file(file_record, 'application/octet-stream', "attachment")
|
224
|
+
else:
|
225
|
+
return await emit_file(file_record, None, "inline")
|
188
226
|
|
189
227
|
@router_fs.put("/{path:path}")
|
190
228
|
@handle_exception
|
@@ -0,0 +1,91 @@
|
|
1
|
+
from lfss.src.config import THUMB_DB, THUMB_SIZE
|
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
|
+
async def _maybe_init_thumb(c: aiosqlite.Cursor):
|
10
|
+
await c.execute('''
|
11
|
+
CREATE TABLE IF NOT EXISTS thumbs (
|
12
|
+
path TEXT PRIMARY KEY,
|
13
|
+
ctime TEXT,
|
14
|
+
thumb BLOB
|
15
|
+
)
|
16
|
+
''')
|
17
|
+
|
18
|
+
async def _get_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str) -> Optional[bytes]:
|
19
|
+
res = await c.execute('''
|
20
|
+
SELECT ctime, thumb FROM thumbs WHERE path = ?
|
21
|
+
''', (path, ))
|
22
|
+
row = await res.fetchone()
|
23
|
+
if row is None:
|
24
|
+
return None
|
25
|
+
# check if ctime matches, if not delete and return None
|
26
|
+
if row[0] != ctime:
|
27
|
+
await _delete_cache_thumb(c, path)
|
28
|
+
return None
|
29
|
+
blob: bytes = row[1]
|
30
|
+
return blob
|
31
|
+
|
32
|
+
async def _save_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str, raw_bytes: bytes) -> bytes:
|
33
|
+
raw_img = Image.open(BytesIO(raw_bytes))
|
34
|
+
raw_img.thumbnail(THUMB_SIZE)
|
35
|
+
img = raw_img.convert('RGB')
|
36
|
+
bio = BytesIO()
|
37
|
+
img.save(bio, 'JPEG')
|
38
|
+
blob = bio.getvalue()
|
39
|
+
await c.execute('''
|
40
|
+
INSERT OR REPLACE INTO thumbs (path, ctime, thumb) VALUES (?, ?, ?)
|
41
|
+
''', (path, ctime, blob))
|
42
|
+
await c.execute('COMMIT') # commit immediately
|
43
|
+
return blob
|
44
|
+
|
45
|
+
async def _delete_cache_thumb(c: aiosqlite.Cursor, path: str):
|
46
|
+
await c.execute('''
|
47
|
+
DELETE FROM thumbs WHERE path = ?
|
48
|
+
''', (path, ))
|
49
|
+
await c.execute('COMMIT')
|
50
|
+
|
51
|
+
async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
|
52
|
+
"""
|
53
|
+
returns [image bytes of thumbnail, mime type] if supported,
|
54
|
+
or None if not supported.
|
55
|
+
Raises FileNotFoundError if file does not exist
|
56
|
+
"""
|
57
|
+
if path.endswith('/'):
|
58
|
+
return None
|
59
|
+
|
60
|
+
async with aiosqlite.connect(THUMB_DB) as conn:
|
61
|
+
cur = await conn.cursor()
|
62
|
+
await _maybe_init_thumb(cur)
|
63
|
+
|
64
|
+
async with unique_cursor() as main_c:
|
65
|
+
fconn = FileConn(main_c)
|
66
|
+
r = await fconn.get_file_record(path)
|
67
|
+
if r is None:
|
68
|
+
await _delete_cache_thumb(cur, path)
|
69
|
+
raise FileNotFoundError(f'File not found: {path}')
|
70
|
+
|
71
|
+
if not r.mime_type.startswith('image/'):
|
72
|
+
return None
|
73
|
+
|
74
|
+
c_time = r.create_time
|
75
|
+
thumb_blob = await _get_cache_thumb(cur, path, c_time)
|
76
|
+
if thumb_blob is not None:
|
77
|
+
return thumb_blob, "image/jpeg"
|
78
|
+
|
79
|
+
# generate thumb
|
80
|
+
async with unique_cursor() as main_c:
|
81
|
+
fconn = FileConn(main_c)
|
82
|
+
if r.external:
|
83
|
+
data = b""
|
84
|
+
async for chunk in fconn.get_file_blob_external(r.file_id):
|
85
|
+
data += chunk
|
86
|
+
else:
|
87
|
+
data = await fconn.get_file_blob(r.file_id)
|
88
|
+
assert data is not None
|
89
|
+
|
90
|
+
thumb_blob = await _save_cache_thumb(cur, path, c_time, data)
|
91
|
+
return thumb_blob, "image/jpeg"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "lfss"
|
3
|
-
version = "0.7.
|
3
|
+
version = "0.7.13"
|
4
4
|
description = "Lightweight file storage service"
|
5
5
|
authors = ["li, mengxun <limengxun45@outlook.com>"]
|
6
6
|
readme = "Readme.md"
|
@@ -15,6 +15,7 @@ aiofiles = "23.*"
|
|
15
15
|
mimesniff = "1.*"
|
16
16
|
fastapi = "0.*"
|
17
17
|
uvicorn = "0.*"
|
18
|
+
pillow = "*"
|
18
19
|
|
19
20
|
[tool.poetry.dev-dependencies]
|
20
21
|
pytest = "*"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
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
|