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_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
restart: unless-stopped restart: unless-stopped
network_mode: host
redis: redis:
image: redis:7-alpine image: redis:7-alpine
volumes: volumes:
- redis_data:/data - redis_data:/data
restart: unless-stopped restart: unless-stopped
network_mode: host
app: app:
network_mode: host
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile

View File

@ -128,10 +128,23 @@ class FileRequest(models.Model):
created_at = fields.DatetimeField(auto_now_add=True) created_at = fields.DatetimeField(auto_now_add=True)
expires_at = fields.DatetimeField(null=True) expires_at = fields.DatetimeField(null=True)
is_active = fields.BooleanField(default=True) is_active = fields.BooleanField(default=True)
# TODO: Add fields for custom form configuration
class Meta: class Meta:
table = "file_requests" 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") User_Pydantic = pydantic_model_creator(User, name="User_Pydantic")
UserIn_Pydantic = pydantic_model_creator(User, name="UserIn_Pydantic", exclude_readonly=True) 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 import APIRouter, Request, Response, Depends, HTTPException, status, Header
from fastapi.responses import StreamingResponse
from typing import Optional from typing import Optional
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from datetime import datetime from datetime import datetime
@ -9,7 +10,7 @@ import base64
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from .auth import get_current_user, verify_password 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 .storage import storage_manager
from .activity import log_activity from .activity import log_activity
@ -126,7 +127,14 @@ def build_href(base_path: str, name: str, is_collection: bool):
path += '/' path += '/'
return 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") propstat = ET.Element("D:propstat")
prop = ET.SubElement(propstat, "D:prop") 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 = ET.SubElement(prop, f"D:{key}")
elem.text = value 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 = ET.SubElement(propstat, "D:status")
status_elem.text = status status_elem.text = status
return propstat 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"]) @router.api_route("/{full_path:path}", methods=["OPTIONS"])
async def webdav_options(full_path: str): async def webdav_options(full_path: str):
return Response( 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)): async def handle_propfind(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
depth = request.headers.get("Depth", "1") depth = request.headers.get("Depth", "1")
full_path = unquote(full_path).strip('/') 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) 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, "creationdate": folder.created_at,
"getlastmodified": folder.updated_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: for file in files:
response = ET.SubElement(multistatus, "D:response") 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, "getlastmodified": file.updated_at,
"getetag": f'"{file.file_hash}"' "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): elif isinstance(resource, Folder):
response = ET.SubElement(multistatus, "D:response") 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, "creationdate": resource.created_at,
"getlastmodified": resource.updated_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"]: if depth in ["1", "infinity"]:
folders = await Folder.filter(owner=current_user, parent=resource, is_deleted=False) 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, "creationdate": folder.created_at,
"getlastmodified": folder.updated_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: for file in files:
response = ET.SubElement(multistatus, "D:response") 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, "getlastmodified": file.updated_at,
"getetag": f'"{file.file_hash}"' "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): elif isinstance(resource, File):
response = ET.SubElement(multistatus, "D:response") 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, "getlastmodified": resource.updated_at,
"getetag": f'"{resource.file_hash}"' "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) 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) 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): async for chunk in storage_manager.get_file(current_user.id, resource.path):
yield chunk yield chunk
return Response( return StreamingResponse(
content=file_iterator(), content=file_iterator(),
media_type=resource.mime_type, media_type=resource.mime_type,
headers={ headers={
@ -652,3 +703,138 @@ async def handle_unlock(request: Request, full_path: str, current_user: User = D
WebDAVLock.remove_lock(full_path) WebDAVLock.remove_lock(full_path)
return Response(status_code=204) 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 fi
echo "Starting database services..." echo "Starting database services..."
docker-compose up -d db redis docker compose up -d db redis
echo "Waiting for database to be ready..." echo "Waiting for database to be ready..."
sleep 5 sleep 5