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