lfss 0.8.4__py3-none-any.whl → 0.9.1__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 +6 -1
- docs/Permission.md +38 -26
- docs/Webdav.md +22 -0
- frontend/api.js +21 -10
- lfss/api/__init__.py +3 -3
- lfss/api/connector.py +13 -7
- lfss/cli/balance.py +3 -3
- lfss/cli/cli.py +5 -10
- lfss/cli/panel.py +8 -0
- lfss/cli/serve.py +4 -2
- lfss/cli/user.py +31 -4
- lfss/cli/vacuum.py +5 -5
- lfss/{src → eng}/config.py +1 -0
- lfss/{src → eng}/connection_pool.py +22 -3
- lfss/{src → eng}/database.py +280 -67
- lfss/{src → eng}/datatype.py +7 -0
- lfss/{src → eng}/error.py +7 -0
- lfss/{src → eng}/thumb.py +10 -9
- lfss/{src → eng}/utils.py +18 -7
- lfss/sql/init.sql +9 -0
- lfss/svc/app.py +9 -0
- lfss/svc/app_base.py +152 -0
- lfss/svc/app_dav.py +374 -0
- lfss/svc/app_native.py +247 -0
- lfss/svc/common_impl.py +270 -0
- lfss/{src/stat.py → svc/request_log.py} +2 -2
- {lfss-0.8.4.dist-info → lfss-0.9.1.dist-info}/METADATA +9 -4
- lfss-0.9.1.dist-info/RECORD +49 -0
- lfss/src/server.py +0 -621
- lfss-0.8.4.dist-info/RECORD +0 -44
- /lfss/{src → eng}/__init__.py +0 -0
- /lfss/{src → eng}/bounded_pool.py +0 -0
- /lfss/{src → eng}/log.py +0 -0
- {lfss-0.8.4.dist-info → lfss-0.9.1.dist-info}/WHEEL +0 -0
- {lfss-0.8.4.dist-info → lfss-0.9.1.dist-info}/entry_points.txt +0 -0
lfss/svc/app_base.py
ADDED
@@ -0,0 +1,152 @@
|
|
1
|
+
import asyncio, time
|
2
|
+
from contextlib import asynccontextmanager
|
3
|
+
from typing import Optional
|
4
|
+
from functools import wraps
|
5
|
+
|
6
|
+
from fastapi import FastAPI, HTTPException, Request, Response, APIRouter, Depends
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
8
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, HTTPBasic, HTTPBasicCredentials
|
9
|
+
|
10
|
+
from ..eng.log import get_logger
|
11
|
+
from ..eng.datatype import UserRecord
|
12
|
+
from ..eng.connection_pool import unique_cursor
|
13
|
+
from ..eng.database import Database, UserConn, delayed_log_activity, DECOY_USER
|
14
|
+
from ..eng.connection_pool import global_connection_init, global_connection_close
|
15
|
+
from ..eng.utils import wait_for_debounce_tasks, now_stamp, hash_credential
|
16
|
+
from ..eng.error import *
|
17
|
+
from ..eng.config import DEBUG_MODE
|
18
|
+
from .request_log import RequestDB
|
19
|
+
|
20
|
+
logger = get_logger("server", term_level="DEBUG")
|
21
|
+
logger_failed_request = get_logger("failed_requests", term_level="INFO")
|
22
|
+
db = Database()
|
23
|
+
req_conn = RequestDB()
|
24
|
+
|
25
|
+
@asynccontextmanager
|
26
|
+
async def lifespan(app: FastAPI):
|
27
|
+
global db
|
28
|
+
try:
|
29
|
+
await global_connection_init(n_read = 2)
|
30
|
+
await asyncio.gather(db.init(), req_conn.init())
|
31
|
+
yield
|
32
|
+
await req_conn.commit()
|
33
|
+
finally:
|
34
|
+
await wait_for_debounce_tasks()
|
35
|
+
await asyncio.gather(req_conn.close(), global_connection_close())
|
36
|
+
|
37
|
+
def handle_exception(fn):
|
38
|
+
@wraps(fn)
|
39
|
+
async def wrapper(*args, **kwargs):
|
40
|
+
try:
|
41
|
+
return await fn(*args, **kwargs)
|
42
|
+
except Exception as e:
|
43
|
+
if isinstance(e, HTTPException):
|
44
|
+
print(f"HTTPException: {e}, detail: {e.detail}")
|
45
|
+
if isinstance(e, HTTPException): raise e
|
46
|
+
if isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
|
47
|
+
if isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
|
48
|
+
if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
|
49
|
+
if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
|
50
|
+
if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
|
51
|
+
if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
|
52
|
+
if isinstance(e, DatabaseLockedError): raise HTTPException(status_code=503, detail=str(e))
|
53
|
+
if isinstance(e, FileLockedError): raise HTTPException(status_code=423, detail=str(e))
|
54
|
+
if isinstance(e, InvalidOptionsError): raise HTTPException(status_code=400, detail=str(e))
|
55
|
+
logger.error(f"Uncaptured error in {fn.__name__}: {e}")
|
56
|
+
raise
|
57
|
+
return wrapper
|
58
|
+
|
59
|
+
app = FastAPI(docs_url=None, redoc_url=None, lifespan=lifespan)
|
60
|
+
app.add_middleware(
|
61
|
+
CORSMiddleware,
|
62
|
+
allow_origins=["*"],
|
63
|
+
allow_credentials=True,
|
64
|
+
allow_methods=["*"],
|
65
|
+
allow_headers=["*"],
|
66
|
+
)
|
67
|
+
|
68
|
+
@app.middleware("http")
|
69
|
+
async def log_requests(request: Request, call_next):
|
70
|
+
|
71
|
+
request_time_stamp = now_stamp()
|
72
|
+
start_time = time.perf_counter()
|
73
|
+
response: Response = await call_next(request)
|
74
|
+
end_time = time.perf_counter()
|
75
|
+
response_time = end_time - start_time
|
76
|
+
response.headers["X-Response-Time"] = str(response_time)
|
77
|
+
|
78
|
+
if response.headers.get("X-Skip-Log", None) is not None:
|
79
|
+
return response
|
80
|
+
|
81
|
+
if response.status_code >= 400:
|
82
|
+
logger_failed_request.error(f"{request.method} {request.url.path} \033[91m{response.status_code}\033[0m")
|
83
|
+
if DEBUG_MODE:
|
84
|
+
print(f"{request.method} {request.url.path} {response.status_code} {response_time:.3f}s")
|
85
|
+
print(f"Request headers: {dict(request.headers)}")
|
86
|
+
await req_conn.log_request(
|
87
|
+
request_time_stamp,
|
88
|
+
request.method, request.url.path, response.status_code, response_time,
|
89
|
+
headers = dict(request.headers),
|
90
|
+
query = dict(request.query_params),
|
91
|
+
client = request.client,
|
92
|
+
request_size = int(request.headers.get("Content-Length", 0)),
|
93
|
+
response_size = int(response.headers.get("Content-Length", 0))
|
94
|
+
)
|
95
|
+
await req_conn.ensure_commit_once()
|
96
|
+
return response
|
97
|
+
|
98
|
+
def skip_request_log(fn):
|
99
|
+
@wraps(fn)
|
100
|
+
async def wrapper(*args, **kwargs):
|
101
|
+
response = await fn(*args, **kwargs)
|
102
|
+
assert isinstance(response, Response), "Response expected"
|
103
|
+
response.headers["X-Skip-Log"] = "1"
|
104
|
+
return response
|
105
|
+
return wrapper
|
106
|
+
|
107
|
+
async def get_credential_from_params(request: Request):
|
108
|
+
return request.query_params.get("token")
|
109
|
+
async def get_current_user(
|
110
|
+
h_token: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
|
111
|
+
b_token: Optional[HTTPBasicCredentials] = Depends(HTTPBasic(auto_error=False)),
|
112
|
+
q_token: Optional[str] = Depends(get_credential_from_params)
|
113
|
+
):
|
114
|
+
"""
|
115
|
+
First try to get the user from the bearer token,
|
116
|
+
if not found, try to get the user from the query parameter
|
117
|
+
"""
|
118
|
+
async with unique_cursor() as conn:
|
119
|
+
uconn = UserConn(conn)
|
120
|
+
if h_token:
|
121
|
+
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
|
+
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"})
|
126
|
+
elif q_token:
|
127
|
+
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"})
|
129
|
+
else:
|
130
|
+
return DECOY_USER
|
131
|
+
|
132
|
+
if not user.id == 0:
|
133
|
+
await delayed_log_activity(user.username)
|
134
|
+
|
135
|
+
return user
|
136
|
+
|
137
|
+
async def registered_user(user: UserRecord = Depends(get_current_user)):
|
138
|
+
if user.id == 0:
|
139
|
+
raise HTTPException(status_code=401, detail="Permission denied", headers={"WWW-Authenticate": "Basic"})
|
140
|
+
return user
|
141
|
+
|
142
|
+
|
143
|
+
router_api = APIRouter(prefix="/_api")
|
144
|
+
router_dav = APIRouter(prefix="")
|
145
|
+
router_fs = APIRouter(prefix="")
|
146
|
+
|
147
|
+
__all__ = [
|
148
|
+
"app", "db", "logger",
|
149
|
+
"handle_exception", "skip_request_log",
|
150
|
+
"router_api", "router_fs", "router_dav",
|
151
|
+
"get_current_user", "registered_user"
|
152
|
+
]
|
lfss/svc/app_dav.py
ADDED
@@ -0,0 +1,374 @@
|
|
1
|
+
""" WebDAV service """
|
2
|
+
|
3
|
+
from fastapi import Request, Response, Depends, HTTPException
|
4
|
+
import time, uuid, os
|
5
|
+
import aiosqlite
|
6
|
+
from typing import Literal, Optional
|
7
|
+
import xml.etree.ElementTree as ET
|
8
|
+
from ..eng.connection_pool import unique_cursor
|
9
|
+
from ..eng.error import *
|
10
|
+
from ..eng.config import DATA_HOME
|
11
|
+
from ..eng.datatype import UserRecord, FileRecord, DirectoryRecord
|
12
|
+
from ..eng.database import FileConn
|
13
|
+
from ..eng.utils import ensure_uri_compnents, decode_uri_compnents, format_last_modified
|
14
|
+
from .app_base import *
|
15
|
+
from .common_impl import get_file_impl, put_file_impl, delete_file_impl
|
16
|
+
|
17
|
+
LOCK_DB_PATH = DATA_HOME / "lock.db"
|
18
|
+
MKDIR_PLACEHOLDER = ".lfss_keep"
|
19
|
+
DAV_NS = "DAV:"
|
20
|
+
|
21
|
+
# at the beginning of the service, remove the lock database
|
22
|
+
try: os.remove(LOCK_DB_PATH)
|
23
|
+
except Exception: ...
|
24
|
+
|
25
|
+
ET.register_namespace("d", DAV_NS) # Register the default namespace
|
26
|
+
ptype = Literal["file", "dir", None]
|
27
|
+
async def eval_path(path: str) -> tuple[ptype, str, Optional[FileRecord | DirectoryRecord]]:
|
28
|
+
"""
|
29
|
+
Evaluate the type of the path,
|
30
|
+
the return value is a uri-safe string,
|
31
|
+
return (ptype, lfss_path, record)
|
32
|
+
|
33
|
+
lfss_path is the path recorded in the database,
|
34
|
+
it should not start with /,
|
35
|
+
and should end with / if it is a directory, otherwise it is a file
|
36
|
+
record is the FileRecord or DirectoryRecord object, it is None if the path does not exist
|
37
|
+
"""
|
38
|
+
path = decode_uri_compnents(path)
|
39
|
+
if "://" in path:
|
40
|
+
if not path.startswith("http://") and not path.startswith("https://"):
|
41
|
+
raise HTTPException(status_code=400, detail="Bad Request, unsupported protocol")
|
42
|
+
# pop the protocol part, host part, and port part
|
43
|
+
path = path.split("/", 3)[-1]
|
44
|
+
route_prefix = router_dav.prefix
|
45
|
+
if route_prefix.startswith("/"): route_prefix = route_prefix[1:]
|
46
|
+
assert path.startswith(route_prefix), "Path should start with the route prefix, got: " + path
|
47
|
+
path = path[len(route_prefix):]
|
48
|
+
|
49
|
+
path = ensure_uri_compnents(path)
|
50
|
+
if path.startswith("/"): path = path[1:]
|
51
|
+
|
52
|
+
# path now is url-safe and without leading slash
|
53
|
+
if path.endswith("/"):
|
54
|
+
lfss_path = path
|
55
|
+
async with unique_cursor() as c:
|
56
|
+
fconn = FileConn(c)
|
57
|
+
if await fconn.count_path_files(path, flat=True) == 0:
|
58
|
+
return None, lfss_path, None
|
59
|
+
return "dir", lfss_path, await fconn.get_path_record(path)
|
60
|
+
|
61
|
+
# not end with /, check if it is a file
|
62
|
+
async with unique_cursor() as c:
|
63
|
+
res = await FileConn(c).get_file_record(path)
|
64
|
+
if res:
|
65
|
+
lfss_path = path
|
66
|
+
return "file", lfss_path, res
|
67
|
+
|
68
|
+
if path == "": return "dir", "", DirectoryRecord("")
|
69
|
+
async with unique_cursor() as c:
|
70
|
+
fconn = FileConn(c)
|
71
|
+
if await fconn.count_path_files(path + "/") > 0:
|
72
|
+
lfss_path = path + "/"
|
73
|
+
return "dir", lfss_path, await fconn.get_path_record(lfss_path)
|
74
|
+
|
75
|
+
return None, path, None
|
76
|
+
|
77
|
+
lock_table_create_sql = """
|
78
|
+
CREATE TABLE IF NOT EXISTS locks (
|
79
|
+
path TEXT PRIMARY KEY,
|
80
|
+
user TEXT,
|
81
|
+
token TEXT,
|
82
|
+
timeout float,
|
83
|
+
lock_time float
|
84
|
+
);
|
85
|
+
"""
|
86
|
+
async def lock_path(user: UserRecord, p: str, token: str, timeout: int = 600):
|
87
|
+
async with aiosqlite.connect(LOCK_DB_PATH) as conn:
|
88
|
+
await conn.execute(lock_table_create_sql)
|
89
|
+
async with conn.execute("SELECT user, timeout, lock_time FROM locks WHERE path=?", (p,)) as cur:
|
90
|
+
row = await cur.fetchone()
|
91
|
+
if row:
|
92
|
+
user_, timeout_, lock_time_ = row
|
93
|
+
curr_time = time.time()
|
94
|
+
if timeout > 0 and curr_time - lock_time_ < timeout_:
|
95
|
+
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()))
|
97
|
+
await conn.commit()
|
98
|
+
async def unlock_path(user: UserRecord, p: str, token: str):
|
99
|
+
async with aiosqlite.connect(LOCK_DB_PATH) as conn:
|
100
|
+
await conn.execute(lock_table_create_sql)
|
101
|
+
async with conn.execute("SELECT user, token FROM locks WHERE path=?", (p,)) as cur:
|
102
|
+
row = await cur.fetchone()
|
103
|
+
if not row: return
|
104
|
+
user_, token_ = row
|
105
|
+
if user_ != user.username or token_ != token:
|
106
|
+
raise FileLockedError(f"Failed to unlock file [{p}] with token {token}")
|
107
|
+
await cur.execute("DELETE FROM locks WHERE path=?", (p,))
|
108
|
+
await conn.commit()
|
109
|
+
async def query_lock_el(p: str, top_el_name: str = f"{{{DAV_NS}}}lockinfo") -> Optional[ET.Element]:
|
110
|
+
async with aiosqlite.connect(LOCK_DB_PATH) as conn:
|
111
|
+
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:
|
113
|
+
row = await cur.fetchone()
|
114
|
+
if not row: return None
|
115
|
+
curr_time = time.time()
|
116
|
+
user_, token, timeout, lock_time = row
|
117
|
+
if timeout > 0 and curr_time - lock_time > timeout:
|
118
|
+
await cur.execute("DELETE FROM locks WHERE path=?", (p,))
|
119
|
+
await conn.commit()
|
120
|
+
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
|
134
|
+
|
135
|
+
async def create_file_xml_element(frecord: FileRecord) -> ET.Element:
|
136
|
+
file_el = ET.Element(f"{{{DAV_NS}}}response")
|
137
|
+
href = ET.SubElement(file_el, f"{{{DAV_NS}}}href")
|
138
|
+
href.text = f"/{frecord.url}"
|
139
|
+
propstat = ET.SubElement(file_el, f"{{{DAV_NS}}}propstat")
|
140
|
+
prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
|
141
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = frecord.url.split("/")[-1]
|
142
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype")
|
143
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(frecord.file_size)
|
144
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(frecord.create_time)
|
145
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}getcontenttype").text = frecord.mime_type
|
146
|
+
lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
|
147
|
+
lock_el = await query_lock_el(frecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
|
148
|
+
if lock_el is not None:
|
149
|
+
lock_discovery.append(lock_el)
|
150
|
+
return file_el
|
151
|
+
|
152
|
+
async def create_dir_xml_element(drecord: DirectoryRecord) -> ET.Element:
|
153
|
+
dir_el = ET.Element(f"{{{DAV_NS}}}response")
|
154
|
+
href = ET.SubElement(dir_el, f"{{{DAV_NS}}}href")
|
155
|
+
href.text = f"/{drecord.url}"
|
156
|
+
propstat = ET.SubElement(dir_el, f"{{{DAV_NS}}}propstat")
|
157
|
+
prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
|
158
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = drecord.url.split("/")[-2]
|
159
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype").append(ET.Element(f"{{{DAV_NS}}}collection"))
|
160
|
+
if drecord.size >= 0:
|
161
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(drecord.create_time)
|
162
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(drecord.size)
|
163
|
+
lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
|
164
|
+
lock_el = await query_lock_el(drecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
|
165
|
+
if lock_el is not None:
|
166
|
+
lock_discovery.append(lock_el)
|
167
|
+
return dir_el
|
168
|
+
|
169
|
+
async def xml_request_body(request: Request) -> Optional[ET.Element]:
|
170
|
+
try:
|
171
|
+
assert request.headers.get("Content-Type") == "application/xml"
|
172
|
+
body = await request.body()
|
173
|
+
return ET.fromstring(body)
|
174
|
+
except Exception as e:
|
175
|
+
return None
|
176
|
+
|
177
|
+
@router_dav.options("/{path:path}")
|
178
|
+
async def dav_options(request: Request, path: str):
|
179
|
+
return Response(headers={
|
180
|
+
"DAV": "1,2",
|
181
|
+
"MS-Author-Via": "DAV",
|
182
|
+
"Allow": "OPTIONS, GET, HEAD, POST, DELETE, TRACE, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK",
|
183
|
+
"Content-Length": "0"
|
184
|
+
})
|
185
|
+
|
186
|
+
@router_dav.get("/{path:path}")
|
187
|
+
@handle_exception
|
188
|
+
async def dav_get(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
189
|
+
ptype, path, _ = await eval_path(path)
|
190
|
+
if ptype is None: raise PathNotFoundError(path)
|
191
|
+
elif ptype == "dir": raise InvalidOptionsError("Directory should not be fetched")
|
192
|
+
else: return await get_file_impl(request, user=user, path=path)
|
193
|
+
|
194
|
+
@router_dav.head("/{path:path}")
|
195
|
+
@handle_exception
|
196
|
+
async def dav_head(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
197
|
+
ptype, path, _ = await eval_path(path)
|
198
|
+
# some clients may send HEAD request to check if the file exists
|
199
|
+
if ptype is None: raise PathNotFoundError(path)
|
200
|
+
elif ptype == "dir": return Response(status_code=200)
|
201
|
+
else: return await get_file_impl(request, user=user, path=path, is_head=True)
|
202
|
+
|
203
|
+
@router_dav.put("/{path:path}")
|
204
|
+
@handle_exception
|
205
|
+
async def dav_put(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
206
|
+
_, path, _ = await eval_path(path)
|
207
|
+
return await put_file_impl(request, user=user, path=path, conflict='overwrite')
|
208
|
+
|
209
|
+
@router_dav.delete("/{path:path}")
|
210
|
+
@handle_exception
|
211
|
+
async def dav_delete(path: str, user: UserRecord = Depends(registered_user)):
|
212
|
+
_, path, _ = await eval_path(path)
|
213
|
+
return await delete_file_impl(user=user, path=path)
|
214
|
+
|
215
|
+
@router_dav.api_route("/{path:path}", methods=["PROPFIND"])
|
216
|
+
@handle_exception
|
217
|
+
async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
218
|
+
if path.startswith("/"): path = path[1:]
|
219
|
+
path = ensure_uri_compnents(path)
|
220
|
+
|
221
|
+
depth = request.headers.get("Depth", "1")
|
222
|
+
# Generate XML response
|
223
|
+
multistatus = ET.Element(f"{{{DAV_NS}}}multistatus")
|
224
|
+
path_type, lfss_path, record = await eval_path(path)
|
225
|
+
logger.info(f"PROPFIND {lfss_path} (depth: {depth})")
|
226
|
+
return_status = 200
|
227
|
+
if path_type == "dir" and depth == "0":
|
228
|
+
# query the directory itself
|
229
|
+
return_status = 200
|
230
|
+
assert isinstance(record, DirectoryRecord)
|
231
|
+
dir_el = await create_dir_xml_element(record)
|
232
|
+
multistatus.append(dir_el)
|
233
|
+
|
234
|
+
elif path_type == "dir":
|
235
|
+
return_status = 207
|
236
|
+
async with unique_cursor() as c:
|
237
|
+
flist = await FileConn(c).list_path_files(lfss_path, flat = True if depth == "infinity" else False)
|
238
|
+
for frecord in flist:
|
239
|
+
if frecord.url.split("/")[-1] == MKDIR_PLACEHOLDER: continue
|
240
|
+
file_el = await create_file_xml_element(frecord)
|
241
|
+
multistatus.append(file_el)
|
242
|
+
|
243
|
+
async with unique_cursor() as c:
|
244
|
+
drecords = await FileConn(c).list_path_dirs(lfss_path)
|
245
|
+
for drecord in drecords:
|
246
|
+
dir_el = await create_dir_xml_element(drecord)
|
247
|
+
multistatus.append(dir_el)
|
248
|
+
|
249
|
+
elif path_type == "file":
|
250
|
+
assert isinstance(record, FileRecord)
|
251
|
+
file_el = await create_file_xml_element(record)
|
252
|
+
multistatus.append(file_el)
|
253
|
+
|
254
|
+
else:
|
255
|
+
raise PathNotFoundError(path)
|
256
|
+
|
257
|
+
xml_response = ET.tostring(multistatus, encoding="utf-8", method="xml")
|
258
|
+
return Response(content=xml_response, media_type="application/xml", status_code=return_status)
|
259
|
+
|
260
|
+
@router_dav.api_route("/{path:path}", methods=["MKCOL"])
|
261
|
+
@handle_exception
|
262
|
+
async def dav_mkcol(path: str, user: UserRecord = Depends(registered_user)):
|
263
|
+
# TODO: implement MKCOL more elegantly
|
264
|
+
if path.endswith("/"): path = path[:-1] # make sure returned path is a file
|
265
|
+
ptype, lfss_path, _ = await eval_path(path)
|
266
|
+
if not ptype is None:
|
267
|
+
raise HTTPException(status_code=409, detail="Conflict")
|
268
|
+
logger.info(f"MKCOL {path}")
|
269
|
+
fpath = lfss_path + "/" + MKDIR_PLACEHOLDER
|
270
|
+
async def _ustream():
|
271
|
+
yield b""
|
272
|
+
await db.save_file(user.username, fpath, _ustream())
|
273
|
+
return Response(status_code=201)
|
274
|
+
|
275
|
+
@router_dav.api_route("/{path:path}", methods=["MOVE"])
|
276
|
+
@handle_exception
|
277
|
+
async def dav_move(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
278
|
+
destination = request.headers.get("Destination")
|
279
|
+
if not destination:
|
280
|
+
raise HTTPException(status_code=400, detail="Destination header is required")
|
281
|
+
|
282
|
+
ptype, lfss_path, _ = await eval_path(path)
|
283
|
+
if ptype is None:
|
284
|
+
raise PathNotFoundError(path)
|
285
|
+
dptype, dlfss_path, ddav_path = await eval_path(destination)
|
286
|
+
if dptype is not None:
|
287
|
+
raise HTTPException(status_code=409, detail="Conflict")
|
288
|
+
|
289
|
+
logger.info(f"MOVE {path} -> {destination}")
|
290
|
+
if ptype == "file":
|
291
|
+
assert not lfss_path.endswith("/"), "File path should not end with /"
|
292
|
+
assert not dlfss_path.endswith("/"), "File path should not end with /"
|
293
|
+
await db.move_file(lfss_path, dlfss_path, user)
|
294
|
+
else:
|
295
|
+
assert ptype == "dir", "Directory path should end with /"
|
296
|
+
assert lfss_path.endswith("/"), "Directory path should end with /"
|
297
|
+
if not dlfss_path.endswith("/"): dlfss_path += "/" # the header destination may not end with /
|
298
|
+
await db.move_path(lfss_path, dlfss_path, user)
|
299
|
+
return Response(status_code=201)
|
300
|
+
|
301
|
+
@router_dav.api_route("/{path:path}", methods=["COPY"])
|
302
|
+
@handle_exception
|
303
|
+
async def dav_copy(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
304
|
+
destination = request.headers.get("Destination")
|
305
|
+
if not destination:
|
306
|
+
raise HTTPException(status_code=400, detail="Destination header is required")
|
307
|
+
|
308
|
+
ptype, lfss_path, _ = await eval_path(path)
|
309
|
+
if ptype is None:
|
310
|
+
raise PathNotFoundError(path)
|
311
|
+
dptype, dlfss_path, ddav_path = await eval_path(destination)
|
312
|
+
if dptype is not None:
|
313
|
+
raise HTTPException(status_code=409, detail="Conflict")
|
314
|
+
|
315
|
+
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)
|
326
|
+
|
327
|
+
@router_dav.api_route("/{path:path}", methods=["LOCK"])
|
328
|
+
@handle_exception
|
329
|
+
async def dav_lock(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
330
|
+
raw_timeout = request.headers.get("Timeout", "Second-3600")
|
331
|
+
if raw_timeout == "Infinite": timeout = -1
|
332
|
+
else:
|
333
|
+
if not raw_timeout.startswith("Second-"):
|
334
|
+
raise HTTPException(status_code=400, detail="Bad Request, invalid timeout: " + raw_timeout + ", expected Second-<seconds> or Infinite")
|
335
|
+
_, timeout_str = raw_timeout.split("-")
|
336
|
+
timeout = int(timeout_str)
|
337
|
+
|
338
|
+
_, path, _ = await eval_path(path)
|
339
|
+
# lock_token = f"opaquelocktoken:{uuid.uuid4().hex}"
|
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"
|
347
|
+
lockdiscovery.append(activelock)
|
348
|
+
lock_response = ET.tostring(response_elem, encoding="utf-8", method="xml")
|
349
|
+
return Response(content=lock_response, media_type="application/xml", status_code=201, headers={
|
350
|
+
"Lock-Token": f"<{lock_token}>"
|
351
|
+
})
|
352
|
+
|
353
|
+
@router_dav.api_route("/{path:path}", methods=["UNLOCK"])
|
354
|
+
@handle_exception
|
355
|
+
async def dav_unlock(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
356
|
+
lock_token = request.headers.get("Lock-Token")
|
357
|
+
if not lock_token:
|
358
|
+
raise HTTPException(status_code=400, detail="Lock-Token header is required")
|
359
|
+
if lock_token.startswith("<") and lock_token.endswith(">"):
|
360
|
+
lock_token = lock_token[1:-1]
|
361
|
+
logger.info(f"UNLOCK {path}, token: {lock_token}")
|
362
|
+
_, path, _ = await eval_path(path)
|
363
|
+
await unlock_path(user, path, lock_token)
|
364
|
+
return Response(status_code=204)
|
365
|
+
|
366
|
+
@router_dav.api_route("/{path:path}", methods=["PROPPATCH"])
|
367
|
+
@handle_exception
|
368
|
+
async def dav_proppatch(request: Request, path: str, user: UserRecord = Depends(registered_user), body: ET.Element = Depends(xml_request_body)):
|
369
|
+
# TODO: implement PROPPATCH
|
370
|
+
print("PROPPATCH", path, body)
|
371
|
+
multistatus = ET.Element(f"{{{DAV_NS}}}multistatus")
|
372
|
+
return Response(content=ET.tostring(multistatus, encoding="utf-8", method="xml"), media_type="application/xml", status_code=207)
|
373
|
+
|
374
|
+
__all__ = ["router_dav"]
|