lfss 0.7.12__tar.gz → 0.7.14__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 (39) hide show
  1. {lfss-0.7.12 → lfss-0.7.14}/PKG-INFO +1 -1
  2. {lfss-0.7.12 → lfss-0.7.14}/frontend/index.html +21 -3
  3. {lfss-0.7.12 → lfss-0.7.14}/frontend/scripts.js +42 -5
  4. {lfss-0.7.12 → lfss-0.7.14}/frontend/styles.css +8 -7
  5. lfss-0.7.14/frontend/thumb.css +10 -0
  6. lfss-0.7.14/frontend/thumb.js +75 -0
  7. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/config.py +3 -0
  8. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/server.py +16 -3
  9. lfss-0.7.14/lfss/src/thumb.py +92 -0
  10. {lfss-0.7.12 → lfss-0.7.14}/pyproject.toml +1 -1
  11. lfss-0.7.12/lfss/src/thumb.py +0 -121
  12. {lfss-0.7.12 → lfss-0.7.14}/Readme.md +0 -0
  13. {lfss-0.7.12 → lfss-0.7.14}/docs/Known_issues.md +0 -0
  14. {lfss-0.7.12 → lfss-0.7.14}/docs/Permission.md +0 -0
  15. {lfss-0.7.12 → lfss-0.7.14}/frontend/api.js +0 -0
  16. {lfss-0.7.12 → lfss-0.7.14}/frontend/info.css +0 -0
  17. {lfss-0.7.12 → lfss-0.7.14}/frontend/info.js +0 -0
  18. {lfss-0.7.12 → lfss-0.7.14}/frontend/popup.css +0 -0
  19. {lfss-0.7.12 → lfss-0.7.14}/frontend/popup.js +0 -0
  20. {lfss-0.7.12 → lfss-0.7.14}/frontend/utils.js +0 -0
  21. {lfss-0.7.12 → lfss-0.7.14}/lfss/cli/balance.py +0 -0
  22. {lfss-0.7.12 → lfss-0.7.14}/lfss/cli/cli.py +0 -0
  23. {lfss-0.7.12 → lfss-0.7.14}/lfss/cli/panel.py +0 -0
  24. {lfss-0.7.12 → lfss-0.7.14}/lfss/cli/serve.py +0 -0
  25. {lfss-0.7.12 → lfss-0.7.14}/lfss/cli/user.py +0 -0
  26. {lfss-0.7.12 → lfss-0.7.14}/lfss/cli/vacuum.py +0 -0
  27. {lfss-0.7.12 → lfss-0.7.14}/lfss/client/__init__.py +0 -0
  28. {lfss-0.7.12 → lfss-0.7.14}/lfss/client/api.py +0 -0
  29. {lfss-0.7.12 → lfss-0.7.14}/lfss/sql/init.sql +0 -0
  30. {lfss-0.7.12 → lfss-0.7.14}/lfss/sql/pragma.sql +0 -0
  31. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/__init__.py +0 -0
  32. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/bounded_pool.py +0 -0
  33. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/connection_pool.py +0 -0
  34. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/database.py +0 -0
  35. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/datatype.py +0 -0
  36. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/error.py +0 -0
  37. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/log.py +0 -0
  38. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/stat.py +0 -0
  39. {lfss-0.7.12 → lfss-0.7.14}/lfss/src/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.7.12
3
+ Version: 0.7.14
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -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; font-size: small;">
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>
@@ -3,6 +3,7 @@ import { permMap } from './api.js';
3
3
  import { showFloatingWindowLineInput, showPopup } from './popup.js';
4
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');
@@ -253,10 +293,7 @@ function refreshFileList(){
253
293
  dirLink.href = '#';
254
294
  const nameDiv = document.createElement('div');
255
295
  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);
296
+ nameDiv.innerHTML = makeThumbHtml(conn, dir);
260
297
  nameDiv.appendChild(dirLink);
261
298
  nameTd.appendChild(nameDiv);
262
299
 
@@ -353,7 +390,7 @@ function refreshFileList(){
353
390
 
354
391
  nameTd.innerHTML = `
355
392
  <div class="filename-container">
356
- <img class="thumb" src="${conn.config.endpoint}/${file.url}?token=${conn.config.token}&thumb=true" />
393
+ ${makeThumbHtml(conn, file)}
357
394
  <span>${asHtmlText(fileName)}</span>
358
395
  </div>
359
396
  `
@@ -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;
@@ -186,14 +194,7 @@ div.filename-container{
186
194
  flex-direction: row;
187
195
  align-items: center;
188
196
  gap: 0.5rem;
189
- height: 32px; /* same as thumbnail */
190
197
  }
191
- img.thumb{
192
- max-height: 32px; /* smaller than backend */
193
- max-width: 32px;
194
- border-radius: 0.25rem;
195
- }
196
-
197
198
  label#upload-file-prefix{
198
199
  width: 800px;
199
200
  text-align: right;
@@ -0,0 +1,10 @@
1
+
2
+ div.thumb{
3
+ display: contents;
4
+ }
5
+ img.thumb{
6
+ height: 32px; /* smaller than backend */
7
+ width: 32px;
8
+ object-fit: contain;
9
+ border-radius: 0.1rem;
10
+ }
@@ -0,0 +1,75 @@
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/html" || "application/xhtml+xml" || "application/xml" || "text/css" || "application/javascript" || "text/javascript" || "application/json" || "text/x-python" || "text/x-java-source" ||
39
+ "application/x-httpd-php" || "text/x-ruby" || "text/x-perl" || "application/x-sh" || "application/sql" || "text/x-csrc" || "text/x-c++src" || "text/x-csharp" || "text/x-go" || "text/x-haskell" ||
40
+ "text/x-lua" || "text/x-markdown" || "application/wasm" || "application/x-tcl" || "text/x-yaml" || "application/x-latex" || "application/x-tex" || "text/x-scss" || "application/x-lisp" ||
41
+ "application/x-rustsrc" || "application/x-ruby" || "text/x-asm":
42
+ return ICON_CODE;
43
+ default:
44
+ return ICON_FILE;
45
+ }
46
+ }
47
+
48
+ function getSafeIconUrl(icon_str){
49
+ // change icon color
50
+ const color = '#345';
51
+ icon_str = icon_str
52
+ .replace(/<svg/, `<svg fill="${color}"`)
53
+ .replace(/<path/, `<path fill="${color}"`);
54
+ return 'data:image/svg+xml,' + encodeURIComponent(icon_str);
55
+ }
56
+
57
+ let thumb_counter = 0;
58
+ /**
59
+ * @param {Connector} c
60
+ * @param {FileRecord | DirectoryRecord} r
61
+ * @returns {string}
62
+ */
63
+ export function makeThumbHtml(c, r){
64
+ function ensureSlashEnd(url){ return url.endsWith('/')? url : url + '/'; }
65
+ const token = c.config.token;
66
+ const mtype = r.mime_type? r.mime_type : 'directory';
67
+ const thumb_id = `thumb-${thumb_counter++}`;
68
+ const url = mtype == 'directory'? ensureSlashEnd(r.url) : r.url;
69
+ return `
70
+ <div class="thumb" id="${thumb_id}"> \
71
+ <img src="${c.config.endpoint}/${url}?token=${token}&thumb=true" alt="${r.url}" class="thumb" \
72
+ onerror="this.src='${getSafeIconUrl(getIconSVGFromMimeType(mtype))}';this.classList.add('thumb-svg');" \
73
+ </div>
74
+ `;
75
+ }
@@ -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)
@@ -101,9 +101,11 @@ async def log_requests(request: Request, call_next):
101
101
  response_time = end_time - start_time
102
102
  response.headers["X-Response-Time"] = str(response_time)
103
103
 
104
+ if response.headers.get("X-Skip-Log", None) is not None:
105
+ return response
106
+
104
107
  if response.status_code >= 400:
105
108
  logger_failed_request.error(f"{request.method} {request.url.path} {response.status_code}")
106
-
107
109
  await req_conn.log_request(
108
110
  request_time_stamp,
109
111
  request.method, request.url.path, response.status_code, response_time,
@@ -114,11 +116,20 @@ async def log_requests(request: Request, call_next):
114
116
  response_size = int(response.headers.get("Content-Length", 0))
115
117
  )
116
118
  await req_conn.ensure_commit_once()
117
-
118
119
  return response
119
120
 
121
+ def skip_request_log(fn):
122
+ @wraps(fn)
123
+ async def wrapper(*args, **kwargs):
124
+ response = await fn(*args, **kwargs)
125
+ assert isinstance(response, Response), "Response expected"
126
+ response.headers["X-Skip-Log"] = "1"
127
+ return response
128
+ return wrapper
129
+
120
130
  router_fs = APIRouter(prefix="")
121
131
 
132
+ @skip_request_log
122
133
  async def emit_thumbnail(
123
134
  path: str, download: bool,
124
135
  create_time: Optional[str] = None
@@ -127,7 +138,9 @@ async def emit_thumbnail(
127
138
  fname = path.split("/")[-2]
128
139
  else:
129
140
  fname = path.split("/")[-1]
130
- thumb_blob, mime_type = await get_thumb(path)
141
+ if (thumb_res := await get_thumb(path)) is None:
142
+ return Response(status_code=415, content="Thumbnail not supported")
143
+ thumb_blob, mime_type = thumb_res
131
144
  disp = "inline" if not download else "attachment"
132
145
  headers = {
133
146
  "Content-Disposition": f"{disp}; filename={fname}.thumb.jpg",
@@ -0,0 +1,92 @@
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
+ await c.execute('CREATE INDEX IF NOT EXISTS thumbs_path_idx ON thumbs (path)')
18
+
19
+ async def _get_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str) -> Optional[bytes]:
20
+ res = await c.execute('''
21
+ SELECT ctime, thumb FROM thumbs WHERE path = ?
22
+ ''', (path, ))
23
+ row = await res.fetchone()
24
+ if row is None:
25
+ return None
26
+ # check if ctime matches, if not delete and return None
27
+ if row[0] != ctime:
28
+ await _delete_cache_thumb(c, path)
29
+ return None
30
+ blob: bytes = row[1]
31
+ return blob
32
+
33
+ async def _save_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str, raw_bytes: bytes) -> bytes:
34
+ raw_img = Image.open(BytesIO(raw_bytes))
35
+ raw_img.thumbnail(THUMB_SIZE)
36
+ img = raw_img.convert('RGB')
37
+ bio = BytesIO()
38
+ img.save(bio, 'JPEG')
39
+ blob = bio.getvalue()
40
+ await c.execute('''
41
+ INSERT OR REPLACE INTO thumbs (path, ctime, thumb) VALUES (?, ?, ?)
42
+ ''', (path, ctime, blob))
43
+ await c.execute('COMMIT') # commit immediately
44
+ return blob
45
+
46
+ async def _delete_cache_thumb(c: aiosqlite.Cursor, path: str):
47
+ await c.execute('''
48
+ DELETE FROM thumbs WHERE path = ?
49
+ ''', (path, ))
50
+ await c.execute('COMMIT')
51
+
52
+ async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
53
+ """
54
+ returns [image bytes of thumbnail, mime type] if supported,
55
+ or None if not supported.
56
+ Raises FileNotFoundError if file does not exist
57
+ """
58
+ if path.endswith('/'):
59
+ return None
60
+
61
+ async with aiosqlite.connect(THUMB_DB) as conn:
62
+ cur = await conn.cursor()
63
+ await _maybe_init_thumb(cur)
64
+
65
+ async with unique_cursor() as main_c:
66
+ fconn = FileConn(main_c)
67
+ r = await fconn.get_file_record(path)
68
+ if r is None:
69
+ await _delete_cache_thumb(cur, path)
70
+ raise FileNotFoundError(f'File not found: {path}')
71
+
72
+ if not r.mime_type.startswith('image/'):
73
+ return None
74
+
75
+ c_time = r.create_time
76
+ thumb_blob = await _get_cache_thumb(cur, path, c_time)
77
+ if thumb_blob is not None:
78
+ return thumb_blob, "image/jpeg"
79
+
80
+ # generate thumb
81
+ async with unique_cursor() as main_c:
82
+ fconn = FileConn(main_c)
83
+ if r.external:
84
+ data = b""
85
+ async for chunk in fconn.get_file_blob_external(r.file_id):
86
+ data += chunk
87
+ else:
88
+ data = await fconn.get_file_blob(r.file_id)
89
+ assert data is not None
90
+
91
+ thumb_blob = await _save_cache_thumb(cur, path, c_time, data)
92
+ return thumb_blob, "image/jpeg"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.7.12"
3
+ version = "0.7.14"
4
4
  description = "Lightweight file storage service"
5
5
  authors = ["li, mengxun <limengxun45@outlook.com>"]
6
6
  readme = "Readme.md"
@@ -1,121 +0,0 @@
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"
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
File without changes
File without changes
File without changes
File without changes