This commit is contained in:
retoor 2025-11-10 00:28:48 +01:00
parent adc861d4b4
commit d90b7ba852
4 changed files with 214 additions and 12 deletions

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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