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 +4 -2
- docs/Enviroment_variables.md +12 -0
- docs/Known_issues.md +3 -1
- frontend/api.js +21 -0
- frontend/scripts.js +49 -2
- lfss/api/connector.py +6 -0
- lfss/eng/database.py +7 -7
- lfss/eng/error.py +2 -0
- lfss/svc/app.py +2 -2
- lfss/svc/app_base.py +8 -7
- lfss/svc/app_dav.py +50 -45
- lfss/svc/app_native.py +11 -3
- lfss/svc/common_impl.py +30 -1
- {lfss-0.9.1.dist-info → lfss-0.9.2.dist-info}/METADATA +5 -3
- {lfss-0.9.1.dist-info → lfss-0.9.2.dist-info}/RECORD +17 -16
- {lfss-0.9.1.dist-info → lfss-0.9.2.dist-info}/WHEEL +0 -0
- {lfss-0.9.1.dist-info → lfss-0.9.2.dist-info}/entry_points.txt +0 -0
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,
|
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
|
482
|
-
|
483
|
-
|
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,
|
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
|
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,
|
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 =
|
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
|
-
|
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
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
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(
|
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
|
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,
|
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
|
-
|
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
|
-
|
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
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
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,
|
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
|
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
|
-
#
|
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
|
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.
|
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=
|
2
|
-
docs/
|
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=
|
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=
|
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=
|
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=
|
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=
|
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=
|
41
|
-
lfss/svc/app_base.py,sha256=
|
42
|
-
lfss/svc/app_dav.py,sha256=
|
43
|
-
lfss/svc/app_native.py,sha256=
|
44
|
-
lfss/svc/common_impl.py,sha256=
|
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.
|
47
|
-
lfss-0.9.
|
48
|
-
lfss-0.9.
|
49
|
-
lfss-0.9.
|
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
|
File without changes
|