lfss 0.2.1__tar.gz → 0.2.4__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.4
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
  }
@@ -13,7 +13,7 @@
13
13
  <label for="endpoint">Endpoint</label>
14
14
  <input type="text" id="endpoint" placeholder="http://localhost:8000" autocomplete="off">
15
15
  </div>
16
- <div class="input-group" style="min-width: 800px;">
16
+ <div class="input-group" style="min-width: 300px;">
17
17
  <label for="token">Token</label>
18
18
  <input type="text" id="token" placeholder="" autocomplete="off">
19
19
  </div>
@@ -0,0 +1,53 @@
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
+ }
31
+
32
+ div.popup-window{
33
+ position: fixed;
34
+ top: 0.5rem;
35
+ right: 1rem;
36
+ border-radius: 0.5rem;
37
+ box-shadow: 0 5px 10px rgba(0,0,0,0.2);
38
+ color: white;
39
+ display: block;
40
+ text-align: left;
41
+ animation: popup-appear 0.5s ease;
42
+ }
43
+
44
+ @keyframes popup-appear{
45
+ from{
46
+ opacity: 0;
47
+ transform: translateX(1rem);
48
+ }
49
+ to{
50
+ opacity: 1;
51
+ transform: translateX(0);
52
+ }
53
+ }
@@ -0,0 +1,130 @@
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
+ }
90
+
91
+ const shownPopups = [];
92
+ export function showPopup(content = '', {
93
+ level = "info",
94
+ width = "auto",
95
+ timeout = 3000,
96
+ showTime = true
97
+ } = {}){
98
+ const popup = document.createElement("div");
99
+ popup.classList.add("popup-window");
100
+ popup.innerHTML = showTime? `<span>[${new Date().toLocaleTimeString()}]</span> ${content}` : content;
101
+ popup.style.width = width;
102
+ const popupHeight = '1rem';
103
+ popup.style.height = popupHeight;
104
+ popup.style.maxHeight = popupHeight;
105
+ popup.style.minHeight = popupHeight;
106
+ const paddingHeight = '1rem';
107
+ popup.style.padding = paddingHeight;
108
+
109
+ // traverse shownPopups and update the top position of each popup
110
+ if (shownPopups.length > 0) {
111
+ for (let i = 0; i < shownPopups.length; i++) {
112
+ shownPopups[i].style.top = `${i * (parseInt(popupHeight) + 2*parseInt(paddingHeight))*1.2 + 0.5}rem`;
113
+ }
114
+ }
115
+ popup.style.top = `${shownPopups.length * (parseInt(popupHeight) + 2*parseInt(paddingHeight))*1.2 + 0.5}rem`;
116
+
117
+ if (level === "error") popup.style.backgroundColor = "darkred";
118
+ if (level === "warning") popup.style.backgroundColor = "darkorange";
119
+ if (level === "info") popup.style.backgroundColor = "darkblue";
120
+ if (level === "success") popup.style.backgroundColor = "darkgreen";
121
+ document.body.appendChild(popup);
122
+ shownPopups.push(popup);
123
+ window.setTimeout(() => {
124
+ if (popup.parentNode) document.body.removeChild(popup);
125
+ shownPopups.splice(shownPopups.indexOf(popup), 1);
126
+ for (let i = 0; i < shownPopups.length; i++) {
127
+ shownPopups[i].style.top = `${i * (parseInt(popupHeight) + 2*parseInt(paddingHeight))*1.2 + 0.5}rem`;
128
+ }
129
+ }, timeout);
130
+ }
@@ -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, showPopup } 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
  });
@@ -166,7 +168,12 @@ Are you sure you want to proceed?
166
168
  let counter = 0;
167
169
  async function uploadFile(...args){
168
170
  const [file, path] = args;
169
- await conn.put(path, file);
171
+ try{
172
+ await conn.put(path, file);
173
+ }
174
+ catch (err){
175
+ showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
176
+ }
170
177
  counter += 1;
171
178
  console.log("Uploading file: ", counter, "/", files.length);
172
179
  }
@@ -259,6 +266,7 @@ function refreshFileList(){
259
266
 
260
267
  const deleteButton = document.createElement('a');
261
268
  deleteButton.textContent = 'Delete';
269
+ deleteButton.classList.add('delete-btn');
262
270
  deleteButton.href = '#';
263
271
  deleteButton.addEventListener('click', () => {
264
272
  const dirurl = dir.url + (dir.url.endsWith('/') ? '' : '/');
@@ -268,6 +276,8 @@ function refreshFileList(){
268
276
  conn.delete(dirurl)
269
277
  .then(() => {
270
278
  refreshFileList();
279
+ }, (err)=>{
280
+ showPopup('Failed to delete path: ' + err, {level: 'error', timeout: 5000});
271
281
  });
272
282
  });
273
283
  actContainer.appendChild(deleteButton);
@@ -330,7 +340,12 @@ function refreshFileList(){
330
340
  console.warn("Permission string mismatch", permStr, permStrFromMap);
331
341
  }
332
342
  }
333
- conn.setFilePermission(file.url, perm)
343
+ conn.setFilePermission(file.url, perm).then(
344
+ () => {},
345
+ (err) => {
346
+ showPopup('Failed to set permission: ' + err, {level: 'error', timeout: 5000});
347
+ }
348
+ );
334
349
  });
335
350
 
336
351
  accessTd.appendChild(select);
@@ -344,10 +359,11 @@ function refreshFileList(){
344
359
  actContainer.classList.add('action-container');
345
360
 
346
361
  const copyButton = document.createElement('a');
347
- copyButton.textContent = 'Copy';
348
- copyButton.href = '#';
362
+ copyButton.style.cursor = 'pointer';
363
+ copyButton.textContent = 'Share';
349
364
  copyButton.addEventListener('click', () => {
350
365
  copyToClipboard(conn.config.endpoint + '/' + file.url);
366
+ showPopup('Link copied to clipboard', {level: "success"});
351
367
  });
352
368
  actContainer.appendChild(copyButton);
353
369
 
@@ -357,6 +373,32 @@ function refreshFileList(){
357
373
  viewButton.target = '_blank';
358
374
  actContainer.appendChild(viewButton);
359
375
 
376
+ const moveButton = document.createElement('a');
377
+ moveButton.textContent = 'Move';
378
+ moveButton.style.cursor = 'pointer';
379
+ moveButton.addEventListener('click', () => {
380
+ showFloatingWindowLineInput((dstPath) => {
381
+ dstPath = encodePathURI(dstPath);
382
+ if (dstPath.endsWith('/')){
383
+ dstPath = dstPath.slice(0, -1);
384
+ }
385
+ conn.moveFile(file.url, dstPath)
386
+ .then(() => {
387
+ refreshFileList();
388
+ },
389
+ (err) => {
390
+ showPopup('Failed to move file: ' + err, {level: 'error'});
391
+ }
392
+ );
393
+ }, {
394
+ text: 'Enter the destination path: ',
395
+ placeholder: 'Destination path',
396
+ value: decodePathURI(file.url),
397
+ select: "last-filename"
398
+ });
399
+ });
400
+ actContainer.appendChild(moveButton);
401
+
360
402
  const downloadBtn = document.createElement('a');
361
403
  downloadBtn.textContent = 'Download';
362
404
  downloadBtn.href = conn.config.endpoint + '/' + file.url + '?asfile=true&token=' + conn.config.token;
@@ -364,6 +406,7 @@ function refreshFileList(){
364
406
 
365
407
  const deleteButton = document.createElement('a');
366
408
  deleteButton.textContent = 'Delete';
409
+ deleteButton.classList.add('delete-btn');
367
410
  deleteButton.href = '#';
368
411
  deleteButton.addEventListener('click', () => {
369
412
  if (!confirm('Are you sure you want to delete ' + file.url + '?')){
@@ -372,6 +415,8 @@ function refreshFileList(){
372
415
  conn.delete(file.url)
373
416
  .then(() => {
374
417
  refreshFileList();
418
+ }, (err) => {
419
+ showPopup('Failed to delete file: ' + err, {level: 'error', timeout: 5000});
375
420
  });
376
421
  });
377
422
  actContainer.appendChild(deleteButton);
@@ -1,3 +1,4 @@
1
+ @import "./popup.css";
1
2
 
2
3
  body{
3
4
  font-family: Arial, sans-serif;
@@ -13,7 +14,7 @@ input[type=button], button{
13
14
  padding: 0.8rem;
14
15
  margin: 0;
15
16
  border: none;
16
- border-radius: 0.2rem;
17
+ border-radius: 0.25rem;
17
18
  cursor: pointer;
18
19
  }
19
20
 
@@ -22,7 +23,7 @@ input[type=text], input[type=password]
22
23
  width: 100%;
23
24
  padding: 0.75rem;
24
25
  border: 1px solid #ccc;
25
- border-radius: 0.2rem;
26
+ border-radius: 0.25rem;
26
27
  height: 1rem;
27
28
  }
28
29
 
@@ -208,4 +209,12 @@ a{
208
209
  background-color: #195f8b;
209
210
  transform: scale(1.1);
210
211
  color: white;
212
+ }
213
+
214
+ .delete-btn{
215
+ color: darkred !important;
216
+ }
217
+ .delete-btn:hover{
218
+ color: white !important;
219
+ background-color: #990511c7 !important;
211
220
  }
@@ -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=["*"],
@@ -203,6 +206,8 @@ router_api = APIRouter(prefix="/_api")
203
206
  @router_api.get("/bundle")
204
207
  async def bundle_files(path: str, user: DBUserRecord = Depends(get_current_user)):
205
208
  logger.info(f"GET bundle({path}), user: {user.username}")
209
+ if user.id == 0:
210
+ raise HTTPException(status_code=401, detail="Permission denied")
206
211
  path = ensure_uri_compnents(path)
207
212
  assert path.endswith("/") or path == ""
208
213
 
@@ -255,6 +260,7 @@ async def get_file_meta(path: str, user: DBUserRecord = Depends(get_current_user
255
260
  async def update_file_meta(
256
261
  path: str,
257
262
  perm: Optional[int] = None,
263
+ new_path: Optional[str] = None,
258
264
  user: DBUserRecord = Depends(get_current_user)
259
265
  ):
260
266
  if user.id == 0:
@@ -271,10 +277,16 @@ async def update_file_meta(
271
277
 
272
278
  if perm is not None:
273
279
  logger.info(f"Update permission of {path} to {perm}")
274
- await conn.file.set_file_record(
280
+ await handle_exception(conn.file.set_file_record)(
275
281
  url = file_record.url,
276
282
  permission = FileReadPermission(perm)
277
283
  )
284
+
285
+ if new_path is not None:
286
+ new_path = ensure_uri_compnents(new_path)
287
+ logger.info(f"Update path of {path} to {new_path}")
288
+ await handle_exception(conn.move_file)(path, new_path)
289
+
278
290
  return Response(status_code=200, content="OK")
279
291
 
280
292
  @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.4"
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