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.
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"]