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.
Files changed (38) hide show
  1. {lfss-0.7.11 → lfss-0.7.13}/PKG-INFO +2 -1
  2. {lfss-0.7.11 → lfss-0.7.13}/frontend/index.html +21 -3
  3. {lfss-0.7.11 → lfss-0.7.13}/frontend/scripts.js +53 -3
  4. {lfss-0.7.11 → lfss-0.7.13}/frontend/styles.css +15 -0
  5. lfss-0.7.13/frontend/thumb.css +16 -0
  6. lfss-0.7.13/frontend/thumb.js +68 -0
  7. {lfss-0.7.11 → lfss-0.7.13}/frontend/utils.js +7 -0
  8. {lfss-0.7.11 → lfss-0.7.13}/lfss/client/__init__.py +3 -3
  9. {lfss-0.7.11 → lfss-0.7.13}/lfss/client/api.py +3 -2
  10. lfss-0.7.13/lfss/src/bounded_pool.py +44 -0
  11. {lfss-0.7.11 → lfss-0.7.13}/lfss/src/config.py +3 -0
  12. {lfss-0.7.11 → lfss-0.7.13}/lfss/src/server.py +65 -27
  13. lfss-0.7.13/lfss/src/thumb.py +91 -0
  14. {lfss-0.7.11 → lfss-0.7.13}/pyproject.toml +2 -1
  15. {lfss-0.7.11 → lfss-0.7.13}/Readme.md +0 -0
  16. {lfss-0.7.11 → lfss-0.7.13}/docs/Known_issues.md +0 -0
  17. {lfss-0.7.11 → lfss-0.7.13}/docs/Permission.md +0 -0
  18. {lfss-0.7.11 → lfss-0.7.13}/frontend/api.js +0 -0
  19. {lfss-0.7.11 → lfss-0.7.13}/frontend/info.css +0 -0
  20. {lfss-0.7.11 → lfss-0.7.13}/frontend/info.js +0 -0
  21. {lfss-0.7.11 → lfss-0.7.13}/frontend/popup.css +0 -0
  22. {lfss-0.7.11 → lfss-0.7.13}/frontend/popup.js +0 -0
  23. {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/balance.py +0 -0
  24. {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/cli.py +0 -0
  25. {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/panel.py +0 -0
  26. {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/serve.py +0 -0
  27. {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/user.py +0 -0
  28. {lfss-0.7.11 → lfss-0.7.13}/lfss/cli/vacuum.py +0 -0
  29. {lfss-0.7.11 → lfss-0.7.13}/lfss/sql/init.sql +0 -0
  30. {lfss-0.7.11 → lfss-0.7.13}/lfss/sql/pragma.sql +0 -0
  31. {lfss-0.7.11 → lfss-0.7.13}/lfss/src/__init__.py +0 -0
  32. {lfss-0.7.11 → lfss-0.7.13}/lfss/src/connection_pool.py +0 -0
  33. {lfss-0.7.11 → lfss-0.7.13}/lfss/src/database.py +0 -0
  34. {lfss-0.7.11 → lfss-0.7.13}/lfss/src/datatype.py +0 -0
  35. {lfss-0.7.11 → lfss-0.7.13}/lfss/src/error.py +0 -0
  36. {lfss-0.7.11 → lfss-0.7.13}/lfss/src/log.py +0 -0
  37. {lfss-0.7.11 → lfss-0.7.13}/lfss/src/stat.py +0 -0
  38. {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.11
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="position-hint" class='disconnected'>
28
- <span></span>
29
- <label></label>
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
- nameTd.appendChild(dirLink);
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
- nameTd.textContent = fileName;
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 ThreadPoolExecutor(n_concurrent) as executor:
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 ThreadPoolExecutor(n_concurrent) as executor:
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
- response = requests.request(method, url, headers=headers, **kwargs)
35
- response.raise_for_status()
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
+
@@ -21,3 +21,6 @@ else:
21
21
  MAX_FILE_BYTES = 512 * 1024 * 1024 # 512MB
22
22
  MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
23
23
  CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming (on large files)
24
+
25
+ THUMB_DB = DATA_HOME / 'thumbs.db'
26
+ THUMB_SIZE = (48, 48)
@@ -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 HTTPException(status_code=500, detail=str(e))
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(path: str, download: bool = False, flat: bool = False, user: UserRecord = Depends(get_current_user)):
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
- fname = path.split("/")[-1]
163
- async def send(media_type: Optional[str] = None, disposition = "attachment"):
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
- return await send(None, "inline")
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.11"
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