diff --git a/docker-compose.yml b/docker-compose.yml index ff1e5bc..4801775 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,14 +10,17 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} restart: unless-stopped - + network_mode: host + redis: image: redis:7-alpine volumes: - redis_data:/data restart: unless-stopped + network_mode: host app: + network_mode: host build: context: . dockerfile: Dockerfile diff --git a/rbox/models.py b/rbox/models.py index 93f38e2..c6ec4fa 100644 --- a/rbox/models.py +++ b/rbox/models.py @@ -128,10 +128,23 @@ class FileRequest(models.Model): created_at = fields.DatetimeField(auto_now_add=True) expires_at = fields.DatetimeField(null=True) is_active = fields.BooleanField(default=True) - # TODO: Add fields for custom form configuration class Meta: table = "file_requests" +class WebDAVProperty(models.Model): + id = fields.IntField(pk=True) + resource_type = fields.CharField(max_length=10) + resource_id = fields.IntField() + namespace = fields.CharField(max_length=255) + name = fields.CharField(max_length=255) + value = fields.TextField() + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "webdav_properties" + unique_together = (("resource_type", "resource_id", "namespace", "name"),) + User_Pydantic = pydantic_model_creator(User, name="User_Pydantic") UserIn_Pydantic = pydantic_model_creator(User, name="UserIn_Pydantic", exclude_readonly=True) diff --git a/rbox/webdav.py b/rbox/webdav.py index 6b0d44a..529c0b4 100644 --- a/rbox/webdav.py +++ b/rbox/webdav.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Request, Response, Depends, HTTPException, status, Header +from fastapi.responses import StreamingResponse from typing import Optional from xml.etree import ElementTree as ET from datetime import datetime @@ -9,7 +10,7 @@ import base64 from urllib.parse import unquote, urlparse from .auth import get_current_user, verify_password -from .models import User, File, Folder +from .models import User, File, Folder, WebDAVProperty from .storage import storage_manager from .activity import log_activity @@ -126,7 +127,14 @@ def build_href(base_path: str, name: str, is_collection: bool): path += '/' return path -def create_propstat_element(props: dict, status: str = "HTTP/1.1 200 OK"): +async def get_custom_properties(resource_type: str, resource_id: int): + props = await WebDAVProperty.filter( + resource_type=resource_type, + resource_id=resource_id + ) + return {(prop.namespace, prop.name): prop.value for prop in props} + +def create_propstat_element(props: dict, custom_props: dict = None, status: str = "HTTP/1.1 200 OK"): propstat = ET.Element("D:propstat") prop = ET.SubElement(propstat, "D:prop") @@ -154,11 +162,46 @@ def create_propstat_element(props: dict, status: str = "HTTP/1.1 200 OK"): elem = ET.SubElement(prop, f"D:{key}") elem.text = value + if custom_props: + for (namespace, name), value in custom_props.items(): + if namespace == "DAV:": + continue + elem = ET.SubElement(prop, f"{{{namespace}}}{name}") + elem.text = value + status_elem = ET.SubElement(propstat, "D:status") status_elem.text = status return propstat +def parse_propfind_body(body: bytes): + if not body: + return None + + try: + root = ET.fromstring(body) + + allprop = root.find(".//{DAV:}allprop") + if allprop is not None: + return "allprop" + + propname = root.find(".//{DAV:}propname") + if propname is not None: + return "propname" + + prop = root.find(".//{DAV:}prop") + if prop is not None: + requested_props = [] + for child in prop: + ns = child.tag.split('}')[0][1:] if '}' in child.tag else "DAV:" + name = child.tag.split('}')[1] if '}' in child.tag else child.tag + requested_props.append((ns, name)) + return requested_props + except: + pass + + return None + @router.api_route("/{full_path:path}", methods=["OPTIONS"]) async def webdav_options(full_path: str): return Response( @@ -174,6 +217,8 @@ async def webdav_options(full_path: str): async def handle_propfind(request: Request, full_path: str, current_user: User = Depends(webdav_auth)): depth = request.headers.get("Depth", "1") full_path = unquote(full_path).strip('/') + body = await request.body() + requested_props = parse_propfind_body(body) resource, parent, exists = await resolve_path(full_path, current_user) @@ -212,7 +257,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User = "creationdate": folder.created_at, "getlastmodified": folder.updated_at } - response.append(create_propstat_element(props)) + custom_props = await get_custom_properties("folder", folder.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None + response.append(create_propstat_element(props, custom_props)) for file in files: response = ET.SubElement(multistatus, "D:response") @@ -228,7 +274,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User = "getlastmodified": file.updated_at, "getetag": f'"{file.file_hash}"' } - response.append(create_propstat_element(props)) + custom_props = await get_custom_properties("file", file.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None + response.append(create_propstat_element(props, custom_props)) elif isinstance(resource, Folder): response = ET.SubElement(multistatus, "D:response") @@ -241,7 +288,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User = "creationdate": resource.created_at, "getlastmodified": resource.updated_at } - response.append(create_propstat_element(props)) + custom_props = await get_custom_properties("folder", resource.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None + response.append(create_propstat_element(props, custom_props)) if depth in ["1", "infinity"]: folders = await Folder.filter(owner=current_user, parent=resource, is_deleted=False) @@ -258,7 +306,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User = "creationdate": folder.created_at, "getlastmodified": folder.updated_at } - response.append(create_propstat_element(props)) + custom_props = await get_custom_properties("folder", folder.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None + response.append(create_propstat_element(props, custom_props)) for file in files: response = ET.SubElement(multistatus, "D:response") @@ -274,7 +323,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User = "getlastmodified": file.updated_at, "getetag": f'"{file.file_hash}"' } - response.append(create_propstat_element(props)) + custom_props = await get_custom_properties("file", file.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None + response.append(create_propstat_element(props, custom_props)) elif isinstance(resource, File): response = ET.SubElement(multistatus, "D:response") @@ -290,7 +340,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User = "getlastmodified": resource.updated_at, "getetag": f'"{resource.file_hash}"' } - response.append(create_propstat_element(props)) + custom_props = await get_custom_properties("file", resource.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None + response.append(create_propstat_element(props, custom_props)) xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True) return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207) @@ -320,7 +371,7 @@ async def handle_get(request: Request, full_path: str, current_user: User = Depe async for chunk in storage_manager.get_file(current_user.id, resource.path): yield chunk - return Response( + return StreamingResponse( content=file_iterator(), media_type=resource.mime_type, headers={ @@ -652,3 +703,138 @@ async def handle_unlock(request: Request, full_path: str, current_user: User = D WebDAVLock.remove_lock(full_path) return Response(status_code=204) + +@router.api_route("/{full_path:path}", methods=["PROPPATCH"]) +async def handle_proppatch(request: Request, full_path: str, current_user: User = Depends(webdav_auth)): + full_path = unquote(full_path).strip('/') + + resource, parent, exists = await resolve_path(full_path, current_user) + + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + + body = await request.body() + if not body: + raise HTTPException(status_code=400, detail="Request body required") + + try: + root = ET.fromstring(body) + except: + raise HTTPException(status_code=400, detail="Invalid XML") + + resource_type = "file" if isinstance(resource, File) else "folder" + resource_id = resource.id + + set_props = [] + remove_props = [] + failed_props = [] + + set_element = root.find(".//{DAV:}set") + if set_element is not None: + prop_element = set_element.find(".//{DAV:}prop") + if prop_element is not None: + for child in prop_element: + ns = child.tag.split('}')[0][1:] if '}' in child.tag else "DAV:" + name = child.tag.split('}')[1] if '}' in child.tag else child.tag + value = child.text or "" + + if ns == "DAV:": + live_props = ["creationdate", "getcontentlength", "getcontenttype", + "getetag", "getlastmodified", "resourcetype"] + if name in live_props: + failed_props.append((ns, name, "409 Conflict")) + continue + + try: + existing_prop = await WebDAVProperty.get_or_none( + resource_type=resource_type, + resource_id=resource_id, + namespace=ns, + name=name + ) + + if existing_prop: + existing_prop.value = value + await existing_prop.save() + else: + await WebDAVProperty.create( + resource_type=resource_type, + resource_id=resource_id, + namespace=ns, + name=name, + value=value + ) + set_props.append((ns, name)) + except Exception as e: + failed_props.append((ns, name, "500 Internal Server Error")) + + remove_element = root.find(".//{DAV:}remove") + if remove_element is not None: + prop_element = remove_element.find(".//{DAV:}prop") + if prop_element is not None: + for child in prop_element: + ns = child.tag.split('}')[0][1:] if '}' in child.tag else "DAV:" + name = child.tag.split('}')[1] if '}' in child.tag else child.tag + + if ns == "DAV:": + failed_props.append((ns, name, "409 Conflict")) + continue + + try: + existing_prop = await WebDAVProperty.get_or_none( + resource_type=resource_type, + resource_id=resource_id, + namespace=ns, + name=name + ) + + if existing_prop: + await existing_prop.delete() + remove_props.append((ns, name)) + else: + failed_props.append((ns, name, "404 Not Found")) + except Exception as e: + failed_props.append((ns, name, "500 Internal Server Error")) + + multistatus = ET.Element("D:multistatus", {"xmlns:D": "DAV:"}) + response_elem = ET.SubElement(multistatus, "D:response") + href = ET.SubElement(response_elem, "D:href") + href.text = f"/webdav/{full_path}" + + if set_props or remove_props: + propstat = ET.SubElement(response_elem, "D:propstat") + prop = ET.SubElement(propstat, "D:prop") + + for ns, name in set_props + remove_props: + if ns == "DAV:": + ET.SubElement(prop, f"D:{name}") + else: + ET.SubElement(prop, f"{{{ns}}}{name}") + + status_elem = ET.SubElement(propstat, "D:status") + status_elem.text = "HTTP/1.1 200 OK" + + if failed_props: + prop_by_status = {} + for ns, name, status_text in failed_props: + if status_text not in prop_by_status: + prop_by_status[status_text] = [] + prop_by_status[status_text].append((ns, name)) + + for status_text, props_list in prop_by_status.items(): + propstat = ET.SubElement(response_elem, "D:propstat") + prop = ET.SubElement(propstat, "D:prop") + + for ns, name in props_list: + if ns == "DAV:": + ET.SubElement(prop, f"D:{name}") + else: + ET.SubElement(prop, f"{{{ns}}}{name}") + + status_elem = ET.SubElement(propstat, "D:status") + status_elem.text = f"HTTP/1.1 {status_text}" + + await log_activity(current_user, "properties_modified", resource_type, resource_id) + + xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True) + return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207) diff --git a/run_dev.sh b/run_dev.sh index 93e1ad6..e67f2ab 100755 --- a/run_dev.sh +++ b/run_dev.sh @@ -9,7 +9,7 @@ if [ ! -f .env ]; then fi echo "Starting database services..." -docker-compose up -d db redis +docker compose up -d db redis echo "Waiting for database to be ready..." sleep 5