lfss 0.2.1__tar.gz → 0.2.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -0,0 +1 @@
1
+ [Safari 中文输入法回车捕获](https://github.com/anse-app/anse/issues/127)
@@ -165,7 +165,10 @@ export default class Connector {
165
165
  */
166
166
  async setFilePermission(path, permission){
167
167
  if (path.startsWith('/')){ path = path.slice(1); }
168
- const res = await fetch(this.config.endpoint + '/_api/fmeta?path=' + path + '&perm=' + permission, {
168
+ const dst = new URL(this.config.endpoint + '/_api/fmeta');
169
+ dst.searchParams.append('path', path);
170
+ dst.searchParams.append('perm', permission);
171
+ const res = await fetch(dst.toString(), {
169
172
  method: 'POST',
170
173
  headers: {
171
174
  'Authorization': 'Bearer ' + this.config.token
@@ -175,4 +178,27 @@ export default class Connector {
175
178
  throw new Error(`Failed to set permission, status code: ${res.status}, message: ${await res.json()}`);
176
179
  }
177
180
  }
181
+
182
+ /**
183
+ * @param {string} path - file path(url)
184
+ * @param {string} newPath - new file path(url)
185
+ */
186
+ async moveFile(path, newPath){
187
+ if (path.startsWith('/')){ path = path.slice(1); }
188
+ if (newPath.startsWith('/')){ newPath = newPath.slice(1); }
189
+ const dst = new URL(this.config.endpoint + '/_api/fmeta');
190
+ dst.searchParams.append('path', path);
191
+ dst.searchParams.append('new_path', newPath);
192
+ const res = await fetch(dst.toString(), {
193
+ method: 'POST',
194
+ headers: {
195
+ 'Authorization': 'Bearer ' + this.config.token,
196
+ 'Content-Type': 'application/www-form-urlencoded'
197
+ },
198
+ });
199
+ if (res.status != 200){
200
+ throw new Error(`Failed to move file, status code: ${res.status}, message: ${await res.json()}`);
201
+ }
202
+ }
203
+
178
204
  }
@@ -0,0 +1,30 @@
1
+
2
+ div.floating-window.blocker{
3
+ position: fixed;
4
+ top: 0;
5
+ left: 0;
6
+ width: 100%;
7
+ height: 100%;
8
+ background-color: rgba(0,0,0,0.25);
9
+ z-index: 100;
10
+ }
11
+
12
+ div.floating-window.window{
13
+ position: fixed;
14
+ top: 50%;
15
+ left: 50%;
16
+ transform: translate(-50%, -50%);
17
+ background-color: white;
18
+ box-shadow: 0 0 10px rgba(0,0,0,0.2);
19
+ border-radius: 0.5rem;
20
+ z-index: 101;
21
+ max-width: 80%;
22
+ max-height: 80%;
23
+ overflow: auto;
24
+ text-align: center;
25
+
26
+ display: flex;
27
+ flex-direction: column;
28
+ justify-content: center;
29
+ align-items: center;
30
+ }
@@ -0,0 +1,89 @@
1
+
2
+
3
+ export function createFloatingWindow(innerHTML = '', {
4
+ onClose = () => {},
5
+ width = "auto",
6
+ height = "auto",
7
+ padding = "20px",
8
+ } = {}){
9
+ const blocker = document.createElement("div");
10
+ blocker.classList.add("floating-window", "blocker");
11
+
12
+ const floatingWindow = document.createElement("div");
13
+ floatingWindow.classList.add("floating-window", "window");
14
+ floatingWindow.id = "floatingWindow";
15
+ floatingWindow.innerHTML = innerHTML;
16
+ floatingWindow.style.width = width;
17
+ floatingWindow.style.height = height;
18
+ floatingWindow.style.padding = padding;
19
+
20
+ const container = document.createElement("div");
21
+ container.classList.add("floating-window", "container");
22
+
23
+ document.body.appendChild(blocker);
24
+ document.body.appendChild(floatingWindow);
25
+
26
+ function closeWindow(){
27
+ onClose();
28
+ if (blocker.parentNode) document.body.removeChild(blocker);
29
+ if (floatingWindow.parentNode) document.body.removeChild(floatingWindow);
30
+ window.removeEventListener("keydown", excapeEvListener);
31
+ }
32
+ blocker.onclick = closeWindow;
33
+
34
+ const excapeEvListener = (event) => {
35
+ event.stopPropagation();
36
+ if (event.key === "Escape") closeWindow();
37
+ }
38
+ window.addEventListener("keydown", excapeEvListener);
39
+
40
+ return [floatingWindow, closeWindow];
41
+ }
42
+
43
+ /* select can be "last-filename" */
44
+ export function showFloatingWindowLineInput(onSubmit = (v) => {}, {
45
+ text = "",
46
+ placeholder = "Enter text",
47
+ value = "",
48
+ select = ""
49
+ } = {}){
50
+ const [floatingWindow, closeWindow] = createFloatingWindow(`
51
+ <div style="margin-bottom: 0.5rem;width: 100%;text-align: left;">${text}</div>
52
+ <div style="display: flex; flex-direction: row; gap: 0.25rem;">
53
+ <input type="text" placeholder="${placeholder}" id="floatingWindowInput" value="${value}" style="min-width: 300px;"/>
54
+ <button id="floatingWindowSubmit">OK</button>
55
+ </div>
56
+ `);
57
+
58
+ /** @type {HTMLInputElement} */
59
+ const input = document.getElementById("floatingWindowInput");
60
+ const submit = document.getElementById("floatingWindowSubmit");
61
+
62
+ input.focus();
63
+ input.addEventListener("keydown", event => {
64
+ if(event.key === "Enter" && input.value && event.isComposing === false){
65
+ submit.click();
66
+ }
67
+ });
68
+
69
+ submit.onclick = () => {
70
+ onSubmit(input.value);
71
+ closeWindow();
72
+ };
73
+
74
+ if (select === "last-filename") {
75
+ const inputVal = input.value;
76
+ let lastSlash = inputVal.lastIndexOf("/");
77
+ if (lastSlash === -1) {
78
+ lastSlash = 0;
79
+ }
80
+ const fname = inputVal.slice(lastSlash + 1);
81
+ let lastDot = fname.lastIndexOf(".");
82
+ if (lastDot === -1) {
83
+ lastDot = fname.length;
84
+ }
85
+ input.setSelectionRange(lastSlash + 1, lastSlash + lastDot + 1);
86
+ }
87
+
88
+ return [floatingWindow, closeWindow];
89
+ }
@@ -1,6 +1,7 @@
1
1
  import Connector from './api.js';
2
2
  import { permMap } from './api.js';
3
- import { formatSize, decodePathURI, ensurePathURI, copyToClipboard, getRandomString, cvtGMT2Local, debounce } from './utils.js';
3
+ import { showFloatingWindowLineInput } from './popup.js';
4
+ import { formatSize, decodePathURI, ensurePathURI, copyToClipboard, getRandomString, cvtGMT2Local, debounce, encodePathURI } from './utils.js';
4
5
 
5
6
  const conn = new Connector();
6
7
  let userRecord = null;
@@ -133,7 +134,8 @@ uploadButton.addEventListener('click', () => {
133
134
  });
134
135
 
135
136
  uploadFileNameInput.addEventListener('keydown', (e) => {
136
- if (e.key === 'Enter'){
137
+ if (e.key === 'Enter' && !e.isComposing){
138
+ e.preventDefault();
137
139
  uploadButton.click();
138
140
  }
139
141
  });
@@ -343,19 +345,41 @@ function refreshFileList(){
343
345
  const actContainer = document.createElement('div');
344
346
  actContainer.classList.add('action-container');
345
347
 
348
+ const viewButton = document.createElement('a');
349
+ viewButton.textContent = 'View';
350
+ viewButton.href = conn.config.endpoint + '/' + file.url + '?token=' + conn.config.token;
351
+ viewButton.target = '_blank';
352
+ actContainer.appendChild(viewButton);
353
+
346
354
  const copyButton = document.createElement('a');
347
- copyButton.textContent = 'Copy';
355
+ copyButton.textContent = 'Share';
348
356
  copyButton.href = '#';
349
357
  copyButton.addEventListener('click', () => {
350
358
  copyToClipboard(conn.config.endpoint + '/' + file.url);
351
359
  });
352
360
  actContainer.appendChild(copyButton);
353
361
 
354
- const viewButton = document.createElement('a');
355
- viewButton.textContent = 'View';
356
- viewButton.href = conn.config.endpoint + '/' + file.url + '?token=' + conn.config.token;
357
- viewButton.target = '_blank';
358
- actContainer.appendChild(viewButton);
362
+ const moveButton = document.createElement('a');
363
+ moveButton.textContent = 'Move';
364
+ moveButton.href = '#';
365
+ moveButton.addEventListener('click', () => {
366
+ showFloatingWindowLineInput((dstPath) => {
367
+ dstPath = encodePathURI(dstPath);
368
+ if (dstPath.endsWith('/')){
369
+ dstPath = dstPath.slice(0, -1);
370
+ }
371
+ conn.moveFile(file.url, dstPath)
372
+ .then(() => {
373
+ refreshFileList();
374
+ });
375
+ }, {
376
+ text: 'Enter the destination path: ',
377
+ placeholder: 'Destination path',
378
+ value: decodePathURI(file.url),
379
+ select: "last-filename"
380
+ });
381
+ });
382
+ actContainer.appendChild(moveButton);
359
383
 
360
384
  const downloadBtn = document.createElement('a');
361
385
  downloadBtn.textContent = 'Download';
@@ -1,3 +1,4 @@
1
+ @import "./popup.css";
1
2
 
2
3
  body{
3
4
  font-family: Arial, sans-serif;
@@ -5,6 +5,7 @@ from abc import ABC, abstractmethod
5
5
  import urllib.parse
6
6
  import dataclasses, hashlib, uuid
7
7
  from contextlib import asynccontextmanager
8
+ from functools import wraps
8
9
  from enum import IntEnum
9
10
  import zipfile, io
10
11
 
@@ -21,6 +22,18 @@ _g_conn: Optional[aiosqlite.Connection] = None
21
22
  def hash_credential(username, password):
22
23
  return hashlib.sha256((username + password).encode()).hexdigest()
23
24
 
25
+ _atomic_lock = Lock()
26
+ def atomic(func):
27
+ """
28
+ Ensure non-reentrancy.
29
+ Can be skipped if the function only executes a single SQL statement.
30
+ """
31
+ @wraps(func)
32
+ async def wrapper(*args, **kwargs):
33
+ async with _atomic_lock:
34
+ return await func(*args, **kwargs)
35
+ return wrapper
36
+
24
37
  class DBConnBase(ABC):
25
38
  logger = get_logger('database', global_instance=True)
26
39
 
@@ -113,6 +126,7 @@ class UserConn(DBConnBase):
113
126
  if res is None: return None
114
127
  return self.parse_record(res)
115
128
 
129
+ @atomic
116
130
  async def create_user(
117
131
  self, username: str, password: str, is_admin: bool = False,
118
132
  max_storage: int = 1073741824, permission: FileReadPermission = FileReadPermission.UNSET
@@ -128,6 +142,7 @@ class UserConn(DBConnBase):
128
142
  assert cursor.lastrowid is not None
129
143
  return cursor.lastrowid
130
144
 
145
+ @atomic
131
146
  async def update_user(
132
147
  self, username: str, password: Optional[str] = None, is_admin: Optional[bool] = None,
133
148
  max_storage: Optional[int] = None, permission: Optional[FileReadPermission] = None
@@ -149,7 +164,10 @@ class UserConn(DBConnBase):
149
164
  if max_storage is None: max_storage = current_record.max_storage
150
165
  if permission is None: permission = current_record.permission
151
166
 
152
- await self.conn.execute("UPDATE user SET credential = ?, is_admin = ?, max_storage = ?, permission = ? WHERE username = ?", (credential, is_admin, max_storage, permission, username))
167
+ await self.conn.execute(
168
+ "UPDATE user SET credential = ?, is_admin = ?, max_storage = ?, permission = ? WHERE username = ?",
169
+ (credential, is_admin, max_storage, int(permission), username)
170
+ )
153
171
  self.logger.info(f"User {username} updated")
154
172
 
155
173
  async def all(self):
@@ -355,6 +373,7 @@ class FileConn(DBConnBase):
355
373
  assert res is not None
356
374
  return res[0] or 0
357
375
 
376
+ @atomic
358
377
  async def set_file_record(
359
378
  self, url: str,
360
379
  owner_id: Optional[int] = None,
@@ -362,10 +381,10 @@ class FileConn(DBConnBase):
362
381
  file_size: Optional[int] = None,
363
382
  permission: Optional[ FileReadPermission ] = None
364
383
  ):
365
- self.logger.debug(f"Updating fmeta {url}: user_id={owner_id}, file_id={file_id}")
366
384
 
367
385
  old = await self.get_file_record(url)
368
386
  if old is not None:
387
+ self.logger.debug(f"Updating fmeta {url}: permission={permission}, owner_id={owner_id}")
369
388
  # should delete the old blob if file_id is changed
370
389
  assert file_id is None, "Cannot update file id"
371
390
  assert file_size is None, "Cannot update file size"
@@ -374,21 +393,37 @@ class FileConn(DBConnBase):
374
393
  if permission is None: permission = old.permission
375
394
  await self.conn.execute(
376
395
  """
377
- UPDATE fmeta SET owner_id = ?, file_id = ?, file_size = ?, permission = ?,
396
+ UPDATE fmeta SET owner_id = ?, permission = ?,
378
397
  access_time = CURRENT_TIMESTAMP WHERE url = ?
379
- """, (owner_id, file_id, file_size, permission, url))
398
+ """, (owner_id, int(permission), url))
380
399
  self.logger.info(f"File {url} updated")
381
400
  else:
401
+ self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}")
382
402
  if permission is None:
383
403
  permission = FileReadPermission.UNSET
384
404
  assert owner_id is not None and file_id is not None and file_size is not None, "Missing required fields"
385
- await self.conn.execute("INSERT INTO fmeta (url, owner_id, file_id, file_size, permission) VALUES (?, ?, ?, ?, ?)", (url, owner_id, file_id, file_size, permission))
405
+ await self.conn.execute(
406
+ "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission) VALUES (?, ?, ?, ?, ?)",
407
+ (url, owner_id, file_id, file_size, int(permission))
408
+ )
386
409
  await self.user_size_inc(owner_id, file_size)
387
410
  self.logger.info(f"File {url} created")
388
411
 
412
+ @atomic
413
+ async def move_file(self, old_url: str, new_url: str):
414
+ old = await self.get_file_record(old_url)
415
+ if old is None:
416
+ raise FileNotFoundError(f"File {old_url} not found")
417
+ new_exists = await self.get_file_record(new_url)
418
+ if new_exists is not None:
419
+ raise FileExistsError(f"File {new_url} already exists")
420
+ async with self.conn.execute("UPDATE fmeta SET url = ? WHERE url = ?", (new_url, old_url)):
421
+ self.logger.info(f"Moved file {old_url} to {new_url}")
422
+
389
423
  async def log_access(self, url: str):
390
424
  await self.conn.execute("UPDATE fmeta SET access_time = CURRENT_TIMESTAMP WHERE url = ?", (url, ))
391
425
 
426
+ @atomic
392
427
  async def delete_file_record(self, url: str):
393
428
  file_record = await self.get_file_record(url)
394
429
  if file_record is None: return
@@ -396,6 +431,7 @@ class FileConn(DBConnBase):
396
431
  await self.user_size_dec(file_record.owner_id, file_record.file_size)
397
432
  self.logger.info(f"Deleted fmeta {url}")
398
433
 
434
+ @atomic
399
435
  async def delete_user_file_records(self, owner_id: int):
400
436
  async with self.conn.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, )) as cursor:
401
437
  res = await cursor.fetchall()
@@ -403,6 +439,7 @@ class FileConn(DBConnBase):
403
439
  await self.conn.execute("DELETE FROM usize WHERE user_id = ?", (owner_id, ))
404
440
  self.logger.info(f"Deleted {len(res)} files for user {owner_id}") # type: ignore
405
441
 
442
+ @atomic
406
443
  async def delete_path_records(self, path: str):
407
444
  """Delete all records with url starting with path"""
408
445
  async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (path + '%', )) as cursor:
@@ -436,18 +473,20 @@ class FileConn(DBConnBase):
436
473
  async def delete_file_blobs(self, file_ids: list[str]):
437
474
  await self.conn.execute("DELETE FROM fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
438
475
 
439
- def _validate_url(url: str, is_file = True) -> bool:
476
+ def validate_url(url: str, is_file = True):
440
477
  ret = not url.startswith('/') and not ('..' in url) and ('/' in url) and not ('//' in url) \
441
478
  and not ' ' in url and not url.startswith('\\') and not url.startswith('_') and not url.startswith('.')
442
479
 
443
480
  if not ret:
444
- return False
481
+ raise InvalidPathError(f"Invalid URL: {url}")
445
482
 
446
483
  if is_file:
447
484
  ret = ret and not url.endswith('/')
448
485
  else:
449
486
  ret = ret and url.endswith('/')
450
- return ret
487
+
488
+ if not ret:
489
+ raise InvalidPathError(f"Invalid URL: {url}")
451
490
 
452
491
  async def get_user(db: "Database", user: int | str) -> Optional[DBUserRecord]:
453
492
  if isinstance(user, str):
@@ -467,6 +506,7 @@ async def transaction(db: "Database"):
467
506
  except Exception as e:
468
507
  db.logger.error(f"Error in transaction: {e}")
469
508
  await db.rollback()
509
+ raise e
470
510
  finally:
471
511
  _transaction_lock.release()
472
512
 
@@ -496,8 +536,7 @@ class Database:
496
536
  await _g_conn.rollback()
497
537
 
498
538
  async def save_file(self, u: int | str, url: str, blob: bytes):
499
- if not _validate_url(url):
500
- raise ValueError(f"Invalid URL: {url}")
539
+ validate_url(url)
501
540
  assert isinstance(blob, bytes), "blob must be bytes"
502
541
 
503
542
  user = await get_user(self, u)
@@ -529,7 +568,7 @@ class Database:
529
568
 
530
569
  # async def read_file_stream(self, url: str): ...
531
570
  async def read_file(self, url: str) -> bytes:
532
- if not _validate_url(url): raise ValueError(f"Invalid URL: {url}")
571
+ validate_url(url)
533
572
 
534
573
  r = await self.file.get_file_record(url)
535
574
  if r is None:
@@ -546,7 +585,7 @@ class Database:
546
585
  return blob
547
586
 
548
587
  async def delete_file(self, url: str) -> Optional[FileDBRecord]:
549
- if not _validate_url(url): raise ValueError(f"Invalid URL: {url}")
588
+ validate_url(url)
550
589
 
551
590
  async with transaction(self):
552
591
  r = await self.file.get_file_record(url)
@@ -556,9 +595,16 @@ class Database:
556
595
  await self.file.delete_file_blob(f_id)
557
596
  await self.file.delete_file_record(url)
558
597
  return r
598
+
599
+ async def move_file(self, old_url: str, new_url: str):
600
+ validate_url(old_url)
601
+ validate_url(new_url)
602
+
603
+ async with transaction(self):
604
+ await self.file.move_file(old_url, new_url)
559
605
 
560
606
  async def delete_path(self, url: str):
561
- if not _validate_url(url, is_file=False): raise ValueError(f"Invalid URL: {url}")
607
+ validate_url(url, is_file=False)
562
608
 
563
609
  async with transaction(self):
564
610
  records = await self.file.get_path_records(url)
@@ -3,4 +3,6 @@ class LFSSExceptionBase(Exception):...
3
3
 
4
4
  class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
5
5
 
6
+ class InvalidPathError(LFSSExceptionBase, ValueError):...
7
+
6
8
  class StorageExceededError(LFSSExceptionBase):...
@@ -37,7 +37,10 @@ def handle_exception(fn):
37
37
  logger.error(f"Error in {fn.__name__}: {e}")
38
38
  if isinstance(e, HTTPException): raise e
39
39
  elif isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
40
- elif isinstance(e, PermissionDeniedError): raise HTTPException(status_code=403, detail=str(e))
40
+ elif isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
41
+ elif isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
42
+ elif isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
43
+ elif isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
41
44
  else: raise HTTPException(status_code=500, detail=str(e))
42
45
  return wrapper
43
46
 
@@ -63,7 +66,7 @@ async def get_current_user(
63
66
  raise HTTPException(status_code=401, detail="Invalid token")
64
67
  return user
65
68
 
66
- app = FastAPI(docs_url=None, redoc_url=None, lifespan=lifespan)
69
+ app = FastAPI(docs_url="/_docs", redoc_url=None, lifespan=lifespan)
67
70
  app.add_middleware(
68
71
  CORSMiddleware,
69
72
  allow_origins=["*"],
@@ -255,6 +258,7 @@ async def get_file_meta(path: str, user: DBUserRecord = Depends(get_current_user
255
258
  async def update_file_meta(
256
259
  path: str,
257
260
  perm: Optional[int] = None,
261
+ new_path: Optional[str] = None,
258
262
  user: DBUserRecord = Depends(get_current_user)
259
263
  ):
260
264
  if user.id == 0:
@@ -271,10 +275,16 @@ async def update_file_meta(
271
275
 
272
276
  if perm is not None:
273
277
  logger.info(f"Update permission of {path} to {perm}")
274
- await conn.file.set_file_record(
278
+ await handle_exception(conn.file.set_file_record)(
275
279
  url = file_record.url,
276
280
  permission = FileReadPermission(perm)
277
281
  )
282
+
283
+ if new_path is not None:
284
+ new_path = ensure_uri_compnents(new_path)
285
+ logger.info(f"Update path of {path} to {new_path}")
286
+ await handle_exception(conn.move_file)(path, new_path)
287
+
278
288
  return Response(status_code=200, content="OK")
279
289
 
280
290
  @router_api.get("/whoami")
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Lightweight file storage service"
5
5
  authors = ["li, mengxun <limengxun45@outlook.com>"]
6
6
  readme = "Readme.md"
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