lfss 0.9.1__py3-none-any.whl → 0.9.2__py3-none-any.whl

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.
Readme.md CHANGED
@@ -34,10 +34,12 @@ The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/ur
34
34
  The authentication can be acheived through one of the following methods:
35
35
  1. `Authorization` header with the value `Bearer sha256(<username><password>)`.
36
36
  2. `token` query parameter with the value `sha256(<username><password>)`.
37
- 3. HTTP Basic Authentication with the username and password.
37
+ 3. HTTP Basic Authentication with the username and password (If WebDAV is enabled).
38
38
 
39
39
  You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
40
40
 
41
41
  By default, the service exposes all files to the public for `GET` requests,
42
42
  but file-listing is restricted to the user's own files.
43
- Please refer to [docs/Permission.md](./docs/Permission.md) for more details on the permission system.
43
+ Please refer to [docs/Permission.md](./docs/Permission.md) for more details on the permission system.
44
+
45
+ More can be found in the [docs](./docs) directory.
@@ -0,0 +1,12 @@
1
+
2
+ # Enviroment variables
3
+
4
+ **Server**
5
+ - `LFSS_DATA`: The directory to store the data. Default is `.storage_data`.
6
+ - `LFSS_WEBDAV`: Enable WebDAV support. Default is `0`, set to `1` to enable.
7
+ - `LFSS_LARGE_FILE`: The size limit of the file to store in the database. Default is `8m`.
8
+ - `LFSS_DEBUG`: Enable debug mode for more verbose logging. Default is `0`, set to `1` to enable.
9
+
10
+ **Client**
11
+ - `LFSS_ENDPOINT`: The fallback server endpoint. Default is `http://localhost:8000`.
12
+ - `LFSS_TOKEN`: The fallback token to authenticate. Should be `sha256(<username><password>)`.
docs/Known_issues.md CHANGED
@@ -1 +1,3 @@
1
- [Safari 中文输入法回车捕获](https://github.com/anse-app/anse/issues/127)
1
+ [Safari 中文输入法回车捕获](https://github.com/anse-app/anse/issues/127)
2
+
3
+ [Word 临时文件](https://answers.microsoft.com/en-us/msoffice/forum/all/mac-os-word-temp-sb-folders-created-on-smb-share/40fda56c-c77c-4365-8fa3-eb87ac814207?page=1)
frontend/api.js CHANGED
@@ -384,6 +384,27 @@ export default class Connector {
384
384
  }
385
385
  }
386
386
 
387
+ /**
388
+ * @param {string} srcPath - file path(url)
389
+ * @param {string} dstPath - new file path(url)
390
+ */
391
+ async copy(srcPath, dstPath){
392
+ if (srcPath.startsWith('/')){ srcPath = srcPath.slice(1); }
393
+ if (dstPath.startsWith('/')){ dstPath = dstPath.slice(1); }
394
+ const dst = new URL(this.config.endpoint + '/_api/copy');
395
+ dst.searchParams.append('src', srcPath);
396
+ dst.searchParams.append('dst', dstPath);
397
+ const res = await fetch(dst.toString(), {
398
+ method: 'POST',
399
+ headers: {
400
+ 'Authorization': 'Bearer ' + this.config.token,
401
+ 'Content-Type': 'application/www-form-urlencoded'
402
+ },
403
+ });
404
+ if (!(res.status == 200 || res.status == 201)){
405
+ throw new Error(`Failed to copy file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
406
+ }
407
+ }
387
408
  }
388
409
 
389
410
  /**
frontend/scripts.js CHANGED
@@ -347,7 +347,7 @@ async function refreshFileList(){
347
347
  }
348
348
  );
349
349
  }, {
350
- text: 'Enter the destination path: ',
350
+ text: 'Enter the destination path (Move): ',
351
351
  placeholder: 'Destination path',
352
352
  value: decodePathURI(dirurl),
353
353
  select: "last-pathname"
@@ -355,6 +355,30 @@ async function refreshFileList(){
355
355
  });
356
356
  actContainer.appendChild(moveButton);
357
357
 
358
+ const copyButton = document.createElement('a');
359
+ copyButton.textContent = 'Copy';
360
+ copyButton.style.cursor = 'pointer';
361
+ copyButton.addEventListener('click', () => {
362
+ showFloatingWindowLineInput((dstPath) => {
363
+ dstPath = encodePathURI(dstPath);
364
+ console.log("Copying", dirurl, "to", dstPath);
365
+ conn.copy(dirurl, dstPath)
366
+ .then(() => {
367
+ refreshFileList();
368
+ },
369
+ (err) => {
370
+ showPopup('Failed to copy path: ' + err, {level: 'error'});
371
+ }
372
+ );
373
+ }, {
374
+ text: 'Enter the destination path (Copy): ',
375
+ placeholder: 'Destination path',
376
+ value: decodePathURI(dirurl),
377
+ select: "last-pathname"
378
+ });
379
+ });
380
+ actContainer.appendChild(copyButton);
381
+
358
382
  const downloadButton = document.createElement('a');
359
383
  downloadButton.textContent = 'Download';
360
384
  downloadButton.href = conn.config.endpoint + '/_api/bundle?' +
@@ -478,7 +502,7 @@ async function refreshFileList(){
478
502
  }
479
503
  );
480
504
  }, {
481
- text: 'Enter the destination path: ',
505
+ text: 'Enter the destination path (Move): ',
482
506
  placeholder: 'Destination path',
483
507
  value: decodePathURI(file.url),
484
508
  select: "last-filename"
@@ -486,6 +510,29 @@ async function refreshFileList(){
486
510
  });
487
511
  actContainer.appendChild(moveButton);
488
512
 
513
+ const copyButton = document.createElement('a');
514
+ copyButton.textContent = 'Copy';
515
+ copyButton.style.cursor = 'pointer';
516
+ copyButton.addEventListener('click', () => {
517
+ showFloatingWindowLineInput((dstPath) => {
518
+ dstPath = encodePathURI(dstPath);
519
+ conn.copy(file.url, dstPath)
520
+ .then(() => {
521
+ refreshFileList();
522
+ },
523
+ (err) => {
524
+ showPopup('Failed to copy file: ' + err, {level: 'error'});
525
+ }
526
+ );
527
+ }, {
528
+ text: 'Enter the destination path (Copy): ',
529
+ placeholder: 'Destination path',
530
+ value: decodePathURI(file.url),
531
+ select: "last-filename"
532
+ });
533
+ });
534
+ actContainer.appendChild(copyButton);
535
+
489
536
  const downloadBtn = document.createElement('a');
490
537
  downloadBtn.textContent = 'Download';
491
538
  downloadBtn.href = conn.config.endpoint + '/' + file.url + '?download=true&token=' + conn.config.token;
lfss/api/connector.py CHANGED
@@ -270,6 +270,12 @@ class Connector:
270
270
  self._fetch_factory('POST', '_api/meta', {'path': path, 'new_path': new_path})(
271
271
  headers = {'Content-Type': 'application/www-form-urlencoded'}
272
272
  )
273
+
274
+ def copy(self, src: str, dst: str):
275
+ """Copy file from src to dst."""
276
+ self._fetch_factory('POST', '_api/copy', {'src': src, 'dst': dst})(
277
+ headers = {'Content-Type': 'application/www-form-urlencoded'}
278
+ )
273
279
 
274
280
  def whoami(self) -> UserRecord:
275
281
  """Gets information about the current user."""
lfss/eng/database.py CHANGED
@@ -467,7 +467,7 @@ class FileConn(DBObjectBase):
467
467
  await self.cur.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url))
468
468
  self.logger.info(f"Moved file {old_url} to {new_url}")
469
469
 
470
- async def move_path(self, old_url: str, new_url: str, conflict_handler: Literal['skip', 'overwrite'] = 'overwrite', user_id: Optional[int] = None):
470
+ async def move_path(self, old_url: str, new_url: str, user_id: Optional[int] = None):
471
471
  assert old_url.endswith('/'), "Old path must end with /"
472
472
  assert new_url.endswith('/'), "New path must end with /"
473
473
  if user_id is None:
@@ -478,11 +478,9 @@ class FileConn(DBObjectBase):
478
478
  res = await cursor.fetchall()
479
479
  for r in res:
480
480
  new_r = new_url + r[0][len(old_url):]
481
- if conflict_handler == 'overwrite':
482
- await self.cur.execute("DELETE FROM fmeta WHERE url = ?", (new_r, ))
483
- elif conflict_handler == 'skip':
484
- if (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))) is not None:
485
- continue
481
+ if await (await self.cur.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))).fetchone():
482
+ self.logger.error(f"File {new_r} already exists on move path: {old_url} -> {new_url}")
483
+ raise FileDuplicateError(f"File {new_r} already exists")
486
484
  await self.cur.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_r, r[0]))
487
485
 
488
486
  async def log_access(self, url: str):
@@ -790,6 +788,8 @@ class Database:
790
788
  if op_user is not None:
791
789
  if await check_path_permission(old_url, op_user, cursor=cur) < AccessLevel.WRITE:
792
790
  raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file {old_url}")
791
+ if await check_path_permission(new_url, op_user, cursor=cur) < AccessLevel.WRITE:
792
+ raise PermissionDeniedError(f"Permission denied: {op_user.username} cannot move file to {new_url}")
793
793
  await fconn.move_file(old_url, new_url)
794
794
 
795
795
  new_mime, _ = mimetypes.guess_type(new_url)
@@ -834,7 +834,7 @@ class Database:
834
834
 
835
835
  async with transaction() as cur:
836
836
  fconn = FileConn(cur)
837
- await fconn.move_path(old_url, new_url, 'overwrite', op_user.id)
837
+ await fconn.move_path(old_url, new_url, op_user.id)
838
838
 
839
839
  # not tested
840
840
  async def copy_path(self, old_url: str, new_url: str, op_user: UserRecord):
lfss/eng/error.py CHANGED
@@ -10,6 +10,8 @@ class DatabaseLockedError(LFSSExceptionBase, sqlite3.DatabaseError):...
10
10
 
11
11
  class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
12
12
 
13
+ class FileDuplicateError(LFSSExceptionBase, FileExistsError):...
14
+
13
15
  class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
14
16
 
15
17
  class InvalidPathError(LFSSExceptionBase, ValueError):...
lfss/svc/app.py CHANGED
@@ -1,9 +1,9 @@
1
+ from .app_base import ENABLE_WEBDAV
1
2
  from .app_native import *
2
- import os
3
3
 
4
4
  # order matters
5
5
  app.include_router(router_api)
6
- if os.environ.get("LFSS_WEBDAV", "0") == "1":
6
+ if ENABLE_WEBDAV:
7
7
  from .app_dav import *
8
8
  app.include_router(router_dav)
9
9
  app.include_router(router_fs)
lfss/svc/app_base.py CHANGED
@@ -1,4 +1,4 @@
1
- import asyncio, time
1
+ import asyncio, time, os
2
2
  from contextlib import asynccontextmanager
3
3
  from typing import Optional
4
4
  from functools import wraps
@@ -17,6 +17,7 @@ from ..eng.error import *
17
17
  from ..eng.config import DEBUG_MODE
18
18
  from .request_log import RequestDB
19
19
 
20
+ ENABLE_WEBDAV = os.environ.get("LFSS_WEBDAV", "0") == "1"
20
21
  logger = get_logger("server", term_level="DEBUG")
21
22
  logger_failed_request = get_logger("failed_requests", term_level="INFO")
22
23
  db = Database()
@@ -47,6 +48,7 @@ def handle_exception(fn):
47
48
  if isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
48
49
  if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
49
50
  if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
51
+ if isinstance(e, FileDuplicateError): raise HTTPException(status_code=409, detail=str(e))
50
52
  if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
51
53
  if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
52
54
  if isinstance(e, DatabaseLockedError): raise HTTPException(status_code=503, detail=str(e))
@@ -119,13 +121,13 @@ async def get_current_user(
119
121
  uconn = UserConn(conn)
120
122
  if h_token:
121
123
  user = await uconn.get_user_by_credential(h_token.credentials)
122
- if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic"})
123
- elif b_token:
124
+ if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic" if ENABLE_WEBDAV else "Bearer"})
125
+ elif ENABLE_WEBDAV and b_token:
124
126
  user = await uconn.get_user_by_credential(hash_credential(b_token.username, b_token.password))
125
- if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic"})
127
+ if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic" if ENABLE_WEBDAV else "Bearer"})
126
128
  elif q_token:
127
129
  user = await uconn.get_user_by_credential(q_token)
128
- if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic"})
130
+ if not user: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Basic" if ENABLE_WEBDAV else "Bearer"})
129
131
  else:
130
132
  return DECOY_USER
131
133
 
@@ -136,10 +138,9 @@ async def get_current_user(
136
138
 
137
139
  async def registered_user(user: UserRecord = Depends(get_current_user)):
138
140
  if user.id == 0:
139
- raise HTTPException(status_code=401, detail="Permission denied", headers={"WWW-Authenticate": "Basic"})
141
+ raise HTTPException(status_code=401, detail="Permission denied", headers={"WWW-Authenticate": "Basic" if ENABLE_WEBDAV else "Bearer"})
140
142
  return user
141
143
 
142
-
143
144
  router_api = APIRouter(prefix="/_api")
144
145
  router_dav = APIRouter(prefix="")
145
146
  router_fs = APIRouter(prefix="")
lfss/svc/app_dav.py CHANGED
@@ -3,16 +3,17 @@
3
3
  from fastapi import Request, Response, Depends, HTTPException
4
4
  import time, uuid, os
5
5
  import aiosqlite
6
+ import asyncio
6
7
  from typing import Literal, Optional
7
8
  import xml.etree.ElementTree as ET
8
9
  from ..eng.connection_pool import unique_cursor
9
10
  from ..eng.error import *
10
- from ..eng.config import DATA_HOME
11
+ from ..eng.config import DATA_HOME, DEBUG_MODE
11
12
  from ..eng.datatype import UserRecord, FileRecord, DirectoryRecord
12
13
  from ..eng.database import FileConn
13
- from ..eng.utils import ensure_uri_compnents, decode_uri_compnents, format_last_modified
14
+ from ..eng.utils import ensure_uri_compnents, decode_uri_compnents, format_last_modified, static_vars
14
15
  from .app_base import *
15
- from .common_impl import get_file_impl, put_file_impl, delete_file_impl
16
+ from .common_impl import get_file_impl, put_file_impl, delete_impl, copy_impl
16
17
 
17
18
  LOCK_DB_PATH = DATA_HOME / "lock.db"
18
19
  MKDIR_PLACEHOLDER = ".lfss_keep"
@@ -79,12 +80,14 @@ CREATE TABLE IF NOT EXISTS locks (
79
80
  path TEXT PRIMARY KEY,
80
81
  user TEXT,
81
82
  token TEXT,
83
+ depth TEXT,
82
84
  timeout float,
83
85
  lock_time float
84
86
  );
85
87
  """
86
- async def lock_path(user: UserRecord, p: str, token: str, timeout: int = 600):
88
+ async def lock_path(user: UserRecord, p: str, token: str, depth: str, timeout: int = 1800):
87
89
  async with aiosqlite.connect(LOCK_DB_PATH) as conn:
90
+ await conn.execute("BEGIN EXCLUSIVE")
88
91
  await conn.execute(lock_table_create_sql)
89
92
  async with conn.execute("SELECT user, timeout, lock_time FROM locks WHERE path=?", (p,)) as cur:
90
93
  row = await cur.fetchone()
@@ -93,10 +96,11 @@ async def lock_path(user: UserRecord, p: str, token: str, timeout: int = 600):
93
96
  curr_time = time.time()
94
97
  if timeout > 0 and curr_time - lock_time_ < timeout_:
95
98
  raise FileLockedError(f"File is locked (by {user_}) [{p}]")
96
- await cur.execute("INSERT OR REPLACE INTO locks VALUES (?, ?, ?, ?, ?)", (p, user.username, token, timeout, time.time()))
99
+ await cur.execute("INSERT OR REPLACE INTO locks VALUES (?, ?, ?, ?, ?, ?)", (p, user.username, token, depth, timeout, time.time()))
97
100
  await conn.commit()
98
101
  async def unlock_path(user: UserRecord, p: str, token: str):
99
102
  async with aiosqlite.connect(LOCK_DB_PATH) as conn:
103
+ await conn.execute("BEGIN EXCLUSIVE")
100
104
  await conn.execute(lock_table_create_sql)
101
105
  async with conn.execute("SELECT user, token FROM locks WHERE path=?", (p,)) as cur:
102
106
  row = await cur.fetchone()
@@ -108,29 +112,32 @@ async def unlock_path(user: UserRecord, p: str, token: str):
108
112
  await conn.commit()
109
113
  async def query_lock_el(p: str, top_el_name: str = f"{{{DAV_NS}}}lockinfo") -> Optional[ET.Element]:
110
114
  async with aiosqlite.connect(LOCK_DB_PATH) as conn:
115
+ await conn.execute("BEGIN EXCLUSIVE")
111
116
  await conn.execute(lock_table_create_sql)
112
- async with conn.execute("SELECT user, token, timeout, lock_time FROM locks WHERE path=?", (p,)) as cur:
117
+ async with conn.execute("SELECT user, token, depth, timeout, lock_time FROM locks WHERE path=?", (p,)) as cur:
113
118
  row = await cur.fetchone()
114
119
  if not row: return None
115
120
  curr_time = time.time()
116
- user_, token, timeout, lock_time = row
121
+ username, token, depth, timeout, lock_time = row
117
122
  if timeout > 0 and curr_time - lock_time > timeout:
118
123
  await cur.execute("DELETE FROM locks WHERE path=?", (p,))
119
124
  await conn.commit()
120
125
  return None
121
- lock_info = ET.Element(top_el_name)
122
- locktype = ET.SubElement(lock_info, f"{{{DAV_NS}}}locktype")
123
- ET.SubElement(locktype, f"{{{DAV_NS}}}write")
124
- lockscope = ET.SubElement(lock_info, f"{{{DAV_NS}}}lockscope")
125
- ET.SubElement(lockscope, f"{{{DAV_NS}}}exclusive")
126
- owner = ET.SubElement(lock_info, f"{{{DAV_NS}}}owner")
127
- owner.text = user_
128
- timeout = ET.SubElement(lock_info, f"{{{DAV_NS}}}timeout")
129
- timeout.text = f"Second-{timeout}"
130
- locktoken = ET.SubElement(lock_info, f"{{{DAV_NS}}}locktoken")
131
- href = ET.SubElement(locktoken, f"{{{DAV_NS}}}href")
132
- href.text = f"{token}"
133
- return lock_info
126
+ lock_info = ET.Element(top_el_name)
127
+ locktype = ET.SubElement(lock_info, f"{{{DAV_NS}}}locktype")
128
+ ET.SubElement(locktype, f"{{{DAV_NS}}}write")
129
+ lockscope = ET.SubElement(lock_info, f"{{{DAV_NS}}}lockscope")
130
+ ET.SubElement(lockscope, f"{{{DAV_NS}}}exclusive")
131
+ owner = ET.SubElement(lock_info, f"{{{DAV_NS}}}owner")
132
+ owner.text = username
133
+ depth_el = ET.SubElement(lock_info, f"{{{DAV_NS}}}depth")
134
+ depth_el.text = depth
135
+ timeout = ET.SubElement(lock_info, f"{{{DAV_NS}}}timeout")
136
+ timeout.text = f"Second-{timeout}"
137
+ locktoken = ET.SubElement(lock_info, f"{{{DAV_NS}}}locktoken")
138
+ href = ET.SubElement(locktoken, f"{{{DAV_NS}}}href")
139
+ href.text = f"{token}"
140
+ return lock_info
134
141
 
135
142
  async def create_file_xml_element(frecord: FileRecord) -> ET.Element:
136
143
  file_el = ET.Element(f"{{{DAV_NS}}}response")
@@ -143,9 +150,9 @@ async def create_file_xml_element(frecord: FileRecord) -> ET.Element:
143
150
  ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(frecord.file_size)
144
151
  ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(frecord.create_time)
145
152
  ET.SubElement(prop, f"{{{DAV_NS}}}getcontenttype").text = frecord.mime_type
146
- lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
147
153
  lock_el = await query_lock_el(frecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
148
154
  if lock_el is not None:
155
+ lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
149
156
  lock_discovery.append(lock_el)
150
157
  return file_el
151
158
 
@@ -160,9 +167,9 @@ async def create_dir_xml_element(drecord: DirectoryRecord) -> ET.Element:
160
167
  if drecord.size >= 0:
161
168
  ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(drecord.create_time)
162
169
  ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(drecord.size)
163
- lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
164
170
  lock_el = await query_lock_el(drecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
165
171
  if lock_el is not None:
172
+ lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
166
173
  lock_discovery.append(lock_el)
167
174
  return dir_el
168
175
 
@@ -185,10 +192,10 @@ async def dav_options(request: Request, path: str):
185
192
 
186
193
  @router_dav.get("/{path:path}")
187
194
  @handle_exception
188
- async def dav_get(request: Request, path: str, user: UserRecord = Depends(registered_user)):
195
+ async def dav_get(request: Request, path: str, user: UserRecord = Depends(get_current_user)):
189
196
  ptype, path, _ = await eval_path(path)
190
197
  if ptype is None: raise PathNotFoundError(path)
191
- elif ptype == "dir": raise InvalidOptionsError("Directory should not be fetched")
198
+ # elif ptype == "dir": raise InvalidOptionsError("Directory should not be fetched")
192
199
  else: return await get_file_impl(request, user=user, path=path)
193
200
 
194
201
  @router_dav.head("/{path:path}")
@@ -210,7 +217,7 @@ async def dav_put(request: Request, path: str, user: UserRecord = Depends(regist
210
217
  @handle_exception
211
218
  async def dav_delete(path: str, user: UserRecord = Depends(registered_user)):
212
219
  _, path, _ = await eval_path(path)
213
- return await delete_file_impl(user=user, path=path)
220
+ return await delete_impl(user=user, path=path)
214
221
 
215
222
  @router_dav.api_route("/{path:path}", methods=["PROPFIND"])
216
223
  @handle_exception
@@ -308,25 +315,17 @@ async def dav_copy(request: Request, path: str, user: UserRecord = Depends(regis
308
315
  ptype, lfss_path, _ = await eval_path(path)
309
316
  if ptype is None:
310
317
  raise PathNotFoundError(path)
311
- dptype, dlfss_path, ddav_path = await eval_path(destination)
318
+ dptype, dlfss_path, _ = await eval_path(destination)
312
319
  if dptype is not None:
313
320
  raise HTTPException(status_code=409, detail="Conflict")
314
321
 
315
322
  logger.info(f"COPY {path} -> {destination}")
316
- if ptype == "file":
317
- assert not lfss_path.endswith("/"), "File path should not end with /"
318
- assert not dlfss_path.endswith("/"), "File path should not end with /"
319
- await db.copy_file(lfss_path, dlfss_path, user)
320
- else:
321
- assert ptype == "dir", "Directory path should end with /"
322
- assert lfss_path.endswith("/"), "Directory path should end with /"
323
- assert dlfss_path.endswith("/"), "Directory path should end with /"
324
- await db.copy_path(lfss_path, dlfss_path, user)
325
- return Response(status_code=201)
323
+ return await copy_impl(op_user=user, src_path=lfss_path, dst_path=dlfss_path)
326
324
 
327
325
  @router_dav.api_route("/{path:path}", methods=["LOCK"])
328
326
  @handle_exception
329
- async def dav_lock(request: Request, path: str, user: UserRecord = Depends(registered_user)):
327
+ @static_vars(lock = asyncio.Lock())
328
+ async def dav_lock(request: Request, path: str, user: UserRecord = Depends(registered_user), body: ET.Element = Depends(xml_request_body)):
330
329
  raw_timeout = request.headers.get("Timeout", "Second-3600")
331
330
  if raw_timeout == "Infinite": timeout = -1
332
331
  else:
@@ -334,16 +333,20 @@ async def dav_lock(request: Request, path: str, user: UserRecord = Depends(regis
334
333
  raise HTTPException(status_code=400, detail="Bad Request, invalid timeout: " + raw_timeout + ", expected Second-<seconds> or Infinite")
335
334
  _, timeout_str = raw_timeout.split("-")
336
335
  timeout = int(timeout_str)
337
-
336
+
337
+ lock_depth = request.headers.get("Depth", "0")
338
338
  _, path, _ = await eval_path(path)
339
339
  # lock_token = f"opaquelocktoken:{uuid.uuid4().hex}"
340
340
  lock_token = f"urn:uuid:{uuid.uuid4()}"
341
- logger.info(f"LOCK {path} (timeout: {timeout}), token: {lock_token}")
342
- await lock_path(user, path, lock_token, timeout=timeout)
343
- response_elem = ET.Element(f"{{{DAV_NS}}}prop")
344
- lockdiscovery = ET.SubElement(response_elem, f"{{{DAV_NS}}}lockdiscovery")
345
- activelock = await query_lock_el(path, top_el_name=f"{{{DAV_NS}}}activelock")
346
- assert activelock is not None, "Lock info should not be None"
341
+ logger.info(f"LOCK {path} (timeout: {timeout}), token: {lock_token}, depth: {lock_depth}")
342
+ if DEBUG_MODE:
343
+ print("Lock-body:", ET.tostring(body, encoding="utf-8", method="xml"))
344
+ async with dav_lock.lock:
345
+ await lock_path(user, path, lock_token, lock_depth, timeout=timeout)
346
+ response_elem = ET.Element(f"{{{DAV_NS}}}prop")
347
+ lockdiscovery = ET.SubElement(response_elem, f"{{{DAV_NS}}}lockdiscovery")
348
+ activelock = await query_lock_el(path, top_el_name=f"{{{DAV_NS}}}activelock")
349
+ assert activelock is not None
347
350
  lockdiscovery.append(activelock)
348
351
  lock_response = ET.tostring(response_elem, encoding="utf-8", method="xml")
349
352
  return Response(content=lock_response, media_type="application/xml", status_code=201, headers={
@@ -352,13 +355,15 @@ async def dav_lock(request: Request, path: str, user: UserRecord = Depends(regis
352
355
 
353
356
  @router_dav.api_route("/{path:path}", methods=["UNLOCK"])
354
357
  @handle_exception
355
- async def dav_unlock(request: Request, path: str, user: UserRecord = Depends(registered_user)):
358
+ async def dav_unlock(request: Request, path: str, user: UserRecord = Depends(registered_user), body: ET.Element = Depends(xml_request_body)):
356
359
  lock_token = request.headers.get("Lock-Token")
357
360
  if not lock_token:
358
361
  raise HTTPException(status_code=400, detail="Lock-Token header is required")
359
362
  if lock_token.startswith("<") and lock_token.endswith(">"):
360
363
  lock_token = lock_token[1:-1]
361
364
  logger.info(f"UNLOCK {path}, token: {lock_token}")
365
+ if DEBUG_MODE:
366
+ print("Unlock-body:", ET.tostring(body, encoding="utf-8", method="xml"))
362
367
  _, path, _ = await eval_path(path)
363
368
  await unlock_path(user, path, lock_token)
364
369
  return Response(status_code=204)
lfss/svc/app_native.py CHANGED
@@ -13,7 +13,7 @@ from ..eng.datatype import (
13
13
  )
14
14
 
15
15
  from .app_base import *
16
- from .common_impl import get_file_impl, put_file_impl, post_file_impl, delete_file_impl
16
+ from .common_impl import get_file_impl, put_file_impl, post_file_impl, delete_impl, copy_impl
17
17
 
18
18
  @router_fs.get("/{path:path}")
19
19
  @handle_exception
@@ -75,7 +75,7 @@ async def post_file(
75
75
  @router_fs.delete("/{path:path}")
76
76
  @handle_exception
77
77
  async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
78
- return await delete_file_impl(path, user)
78
+ return await delete_impl(path, user)
79
79
 
80
80
 
81
81
  @router_api.get("/bundle")
@@ -181,11 +181,19 @@ async def update_file_meta(
181
181
  if new_path is not None:
182
182
  new_path = ensure_uri_compnents(new_path)
183
183
  logger.info(f"Update path of {path} to {new_path}")
184
- # currently only move own file, with overwrite
184
+ # will raise duplicate path error if same name path exists in the new path
185
185
  await db.move_path(path, new_path, user)
186
186
 
187
187
  return Response(status_code=200, content="OK")
188
188
 
189
+ @router_api.post("/copy")
190
+ @handle_exception
191
+ async def copy_file(
192
+ src: str, dst: str,
193
+ user: UserRecord = Depends(registered_user)
194
+ ):
195
+ return await copy_impl(src_path = src, dst_path = dst, op_user = user)
196
+
189
197
  async def validate_path_read_permission(path: str, user: UserRecord):
190
198
  if not path.endswith("/"):
191
199
  raise HTTPException(status_code=400, detail="Path must end with /")
lfss/svc/common_impl.py CHANGED
@@ -252,7 +252,7 @@ async def post_file_impl(
252
252
  "Content-Type": "application/json",
253
253
  }, content=json.dumps({"url": path}))
254
254
 
255
- async def delete_file_impl(path: str, user: UserRecord):
255
+ async def delete_impl(path: str, user: UserRecord):
256
256
  path = ensure_uri_compnents(path)
257
257
  if await check_path_permission(path, user) < AccessLevel.WRITE:
258
258
  raise HTTPException(status_code=403, detail="Permission denied")
@@ -268,3 +268,32 @@ async def delete_file_impl(path: str, user: UserRecord):
268
268
  return Response(status_code=200, content="Deleted")
269
269
  else:
270
270
  return Response(status_code=404, content="Not found")
271
+
272
+ async def copy_impl(
273
+ op_user: UserRecord, src_path: str, dst_path: str,
274
+ ):
275
+ src_path = ensure_uri_compnents(src_path)
276
+ dst_path = ensure_uri_compnents(dst_path)
277
+ copy_type = "file" if not src_path[-1] == "/" else "directory"
278
+ if (src_path[-1] == "/") != (dst_path[-1] == "/"):
279
+ raise HTTPException(status_code=400, detail="Source and destination must be same type")
280
+
281
+ if src_path == dst_path:
282
+ raise HTTPException(status_code=400, detail="Source and destination are the same")
283
+
284
+ logger.info(f"Copy {src_path} to {dst_path}, user: {op_user.username}")
285
+ if copy_type == "file":
286
+ async with unique_cursor() as cur:
287
+ fconn = FileConn(cur)
288
+ dst_record = await fconn.get_file_record(dst_path)
289
+ if dst_record:
290
+ raise HTTPException(status_code=409, detail="Destination exists")
291
+ await db.copy_file(src_path, dst_path, op_user)
292
+ else:
293
+ async with unique_cursor() as cur:
294
+ fconn = FileConn(cur)
295
+ dst_fcount = await fconn.count_path_files(dst_path, flat=True)
296
+ if dst_fcount > 0:
297
+ raise HTTPException(status_code=409, detail="Destination exists")
298
+ await db.copy_path(src_path, dst_path, op_user)
299
+ return Response(status_code=201, content="OK")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.9.1
3
+ Version: 0.9.2
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li_mengxun
@@ -57,10 +57,12 @@ The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/ur
57
57
  The authentication can be acheived through one of the following methods:
58
58
  1. `Authorization` header with the value `Bearer sha256(<username><password>)`.
59
59
  2. `token` query parameter with the value `sha256(<username><password>)`.
60
- 3. HTTP Basic Authentication with the username and password.
60
+ 3. HTTP Basic Authentication with the username and password (If WebDAV is enabled).
61
61
 
62
62
  You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
63
63
 
64
64
  By default, the service exposes all files to the public for `GET` requests,
65
65
  but file-listing is restricted to the user's own files.
66
- Please refer to [docs/Permission.md](./docs/Permission.md) for more details on the permission system.
66
+ Please refer to [docs/Permission.md](./docs/Permission.md) for more details on the permission system.
67
+
68
+ More can be found in the [docs](./docs) directory.
@@ -1,8 +1,9 @@
1
- Readme.md,sha256=6gOvhb93ma83VKC4-pfi4TccZxCq4kj3GEB9qa9ols4,1759
2
- docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
1
+ Readme.md,sha256=JVe9T6N1Rz4hTiiCVoDYe2VB0dAi60VcBgb2twQdfZc,1834
2
+ docs/Enviroment_variables.md,sha256=LUZF1o70emp-5UPsvXPjcxapP940OqEZzSyyUUT9bEQ,569
3
+ docs/Known_issues.md,sha256=ZqETcWP8lzTOel9b2mxEgCnADFF8IxOrEtiVO1NoMAk,251
3
4
  docs/Permission.md,sha256=mvK8gVBBgoIFJqikcaReU_bUo-mTq_ECqJaDDJoQF7Q,3126
4
5
  docs/Webdav.md,sha256=9Q41ROEJodVVAnlo1Tf0jqsyrbuHhv_ElSsXbIPXYIg,1547
5
- frontend/api.js,sha256=hMV6Fc1JxkFQgv7BV1Y_Su7pqsWeF_92hPMmDBcXC04,18485
6
+ frontend/api.js,sha256=GlQsNoZFEcy7QUUsLbXv7aP-KxRnIxM37FQHTaakGiQ,19387
6
7
  frontend/index.html,sha256=-k0bJ5FRqdl_H-O441D_H9E-iejgRCaL_z5UeYaS2qc,3384
7
8
  frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
8
9
  frontend/info.js,sha256=xGUJPCSrtDhuSu0ELLQZ77PmVWldg-prU1mwQGbdEoA,5797
@@ -10,14 +11,14 @@ frontend/login.css,sha256=VMM0QfbDFYerxKWKSGhMI1yg5IRBXg0TTdLJEEhQZNk,355
10
11
  frontend/login.js,sha256=QoO8yKmBHDVP-ZomCMOaV7xVUVIhpl7esJrb6T5aHQE,2466
11
12
  frontend/popup.css,sha256=TJZYFW1ZcdD1IVTlNPYNtMWKPbN6XDbQ4hKBOFK8uLg,1284
12
13
  frontend/popup.js,sha256=3PgaGZmxSdV1E-D_MWgcR7aHWkcsHA1BNKSOkmP66tA,5191
13
- frontend/scripts.js,sha256=nWH6NgavZTVmjK44i2DeRi6mJzGSe4qeQPUbDaEVt58,21735
14
+ frontend/scripts.js,sha256=2-Omsb1-s4Wc859_SYw8JGyeUSiADaH9va4w87Mozns,24134
14
15
  frontend/state.js,sha256=vbNL5DProRKmSEY7xu9mZH6IY0PBenF8WGxPtGgDnLI,1680
15
16
  frontend/styles.css,sha256=xcNLqI3KBsY5TLnku8UIP0Jfr7QLajr1_KNlZj9eheM,4935
16
17
  frontend/thumb.css,sha256=rNsx766amYS2DajSQNabhpQ92gdTpNoQKmV69OKvtpI,295
17
18
  frontend/thumb.js,sha256=46ViD2TlTTWy0fx6wjoAs_5CQ4ajYB90vVzM7UO2IHw,6182
18
19
  frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
19
20
  lfss/api/__init__.py,sha256=8IJqrpWK1doIyVVbntvVic82A57ncwl5b0BRHX4Ri6A,6660
20
- lfss/api/connector.py,sha256=gLn-eW1m6trjqj54YXzPqByQFT56WlSy08kUm1UX4LE,11573
21
+ lfss/api/connector.py,sha256=hHSEEWecKQGZH6oxAmYoG3q7lFfacCbOKVZiUIXT2y8,11819
21
22
  lfss/cli/__init__.py,sha256=lPwPmqpa7EXQ4zlU7E7LOe6X2kw_xATGdwoHphUEirA,827
22
23
  lfss/cli/balance.py,sha256=fUbKKAUyaDn74f7mmxMfBL4Q4voyBLHu6Lg_g8GfMOQ,4121
23
24
  lfss/cli/cli.py,sha256=aYjB8d4k6JUd9efxZK-XOj-mlG4JeOr_0lnj2qqCiK0,8066
@@ -29,21 +30,21 @@ lfss/eng/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
30
  lfss/eng/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
30
31
  lfss/eng/config.py,sha256=DmnUYMeLOL-45OstysyMpSBPmLofgzvcSrsWjHvssYs,915
31
32
  lfss/eng/connection_pool.py,sha256=-tePasJxiZZ73ymgWf_kFnaKouc4Rrr4K6EXwjb7Mm4,6141
32
- lfss/eng/database.py,sha256=81wp6LczdByk05RYcJfLjd0tx4ZT3Ue2k994UYMiDZI,47172
33
+ lfss/eng/database.py,sha256=cfMq7Hgj8cHFtynDzpRiqb0XYNb6OKWMYc8PcWl8eVw,47285
33
34
  lfss/eng/datatype.py,sha256=27UB7-l9SICy5lAvKjdzpTL_GohZjzstQcr9PtAq7nM,2709
34
- lfss/eng/error.py,sha256=61hcjeQ-y5htKhImDtDuWDf0GPrwbj5LySibeHiKFxc,529
35
+ lfss/eng/error.py,sha256=sDbXo2R3APJAV0KtoYGCHx2qVZso7svtDzq-WjnzhAw,595
35
36
  lfss/eng/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
36
37
  lfss/eng/thumb.py,sha256=YO1yTI8WzW7pBpQN9x5PtPayxhftb32IJl1zPSS9mks,3243
37
38
  lfss/eng/utils.py,sha256=zZ7r9BsNV8XJJVNOxfIqRCO1bxNzh7bc9vEJiCkgbKI,6208
38
39
  lfss/sql/init.sql,sha256=8LjHx0TBCkBD62xFfssSeHDqKYVQQJkZAg4rSm046f4,1496
39
40
  lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
40
- lfss/svc/app.py,sha256=XK0hx5yKMk8JViJ2BXgsFr3hgSdWn_equJ0JoJwMiuc,221
41
- lfss/svc/app_base.py,sha256=LjH9tJzaFcP7PB8OBUizG9kvNhts2eq0JxNMBUCoHw0,6312
42
- lfss/svc/app_dav.py,sha256=8Etdoh_NmvZpEG8J9m97xRF4i-GNGJNvNfgjfLIDjzk,17224
43
- lfss/svc/app_native.py,sha256=ML-PR-zdoi5j3-KrGhIaYPMbSoW9Lesh7NROPNPUINU,9221
44
- lfss/svc/common_impl.py,sha256=m3lMA97a4yd_VFG3IrVYIsDzyUEseX7sU633qj7iyDI,10858
41
+ lfss/svc/app.py,sha256=ftWCpepBx-gTSG7i-TB-IdinPPstAYYQjCgnTfeMZeI,219
42
+ lfss/svc/app_base.py,sha256=nc02DP4iMKP41fRl8M-iAhbHwyb4QJJTKKSJwtdCox4,6617
43
+ lfss/svc/app_dav.py,sha256=nPMdPsYNcgxqHOt5bDaaA0Wy8AdRDJajEda_-KxOoHA,17466
44
+ lfss/svc/app_native.py,sha256=xwMCOWp4ne3rmtiiYhfxETi__V-zPEfHw-c4iWNtXWc,9471
45
+ lfss/svc/common_impl.py,sha256=_biK0F_AAw4PnMNWR0WuHJSRyIp1iTSOOIPBauZCJ9M,12143
45
46
  lfss/svc/request_log.py,sha256=v8yXEIzPjaksu76Oh5vgdbUEUrw8Kt4etLAXBWSGie8,3207
46
- lfss-0.9.1.dist-info/METADATA,sha256=CZuVoVTKJG002grUK_hXtgy0b_j3m-eNHDK0SRyVLL4,2519
47
- lfss-0.9.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
48
- lfss-0.9.1.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
49
- lfss-0.9.1.dist-info/RECORD,,
47
+ lfss-0.9.2.dist-info/METADATA,sha256=0Q5klZ2iwBF1ZUQ5iximW02mMmoAM5ib08s0IsdyuLE,2594
48
+ lfss-0.9.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
49
+ lfss-0.9.2.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
50
+ lfss-0.9.2.dist-info/RECORD,,
File without changes