This commit is contained in:
retoor 2025-10-03 04:28:54 +02:00
parent d4aee28247
commit 9a0a6ce0fa
4 changed files with 144 additions and 29 deletions

5
.gitignore vendored
View File

@ -2,3 +2,8 @@
.env
__pycache__/
logs/
webdav
debug_webdav.py
test_propfind.sh
test_cache.py
*.db

144
main.py
View File

@ -13,6 +13,7 @@ import hmac
import secrets
import mimetypes
import base64
import functools
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Dict, List, Tuple
@ -75,12 +76,17 @@ class Database:
def __init__(self, db_path: str):
self.db_path = db_path
self._connection_lock = asyncio.Lock()
self.init_database()
def get_connection(self) -> sqlite3.Connection:
"""Get database connection with row factory"""
conn = sqlite3.connect(self.db_path)
conn = sqlite3.connect(self.db_path, timeout=30.0, check_same_thread=False)
conn.row_factory = sqlite3.Row
# Enable WAL mode for better concurrent access
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('PRAGMA busy_timeout=30000')
conn.execute('PRAGMA synchronous=NORMAL')
return conn
def init_database(self):
@ -172,6 +178,7 @@ class Database:
salt = secrets.token_hex(16)
password_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000).hex()
def _create():
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('''
@ -181,6 +188,10 @@ class Database:
user_id = cursor.lastrowid
conn.commit()
conn.close()
return user_id
# Run in thread pool to prevent blocking
user_id = await asyncio.get_event_loop().run_in_executor(None, _create)
# Create user directory
user_dir = Path(Config.WEBDAV_ROOT) / 'users' / username
@ -188,7 +199,17 @@ class Database:
return user_id
async def verify_user(self, username: str, password: str) -> Optional[Dict]:
def _verify_user(self, username: str, password: str) -> Optional[Dict]:
"""Verify user credentials"""
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM users WHERE username = ? AND is_active = 1', (username,))
user = cursor.fetchone()
conn.close()
return dict(user) if user else None
@functools.lru_cache()
def _verify_user(self, username: str, password: str) -> Optional[Dict]:
"""Verify user credentials"""
conn = self.get_connection()
cursor = conn.cursor()
@ -205,6 +226,10 @@ class Database:
return dict(user)
return None
async def verify_user(self, username: str, password: str) -> Optional[Dict]:
"""Verify user credentials"""
return self._verify_user(username, password)
async def get_user_by_id(self, user_id: int) -> Optional[Dict]:
"""Get user by ID"""
conn = self.get_connection()
@ -267,6 +292,18 @@ class Database:
conn.commit()
conn.close()
async def remove_property(self, resource_path: str, namespace: str, property_name: str):
"""Remove a custom property from a resource"""
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('''
DELETE FROM properties
WHERE resource_path = ? AND property_name = ?
AND (namespace = ? OR (namespace IS NULL AND ? = ''))
''', (resource_path, property_name, namespace, namespace))
conn.commit()
conn.close()
async def get_properties(self, resource_path: str) -> List[Dict]:
"""Get all properties for a resource"""
conn = self.get_connection()
@ -289,7 +326,8 @@ class WebDAVXML:
# WebDAV namespaces
NS = {
'D': 'DAV:',
'MS': 'urn:schemas-microsoft-com:'
'MS': 'urn:schemas-microsoft-com:',
'C': 'http://webdav.org/custom/' # Custom properties namespace
}
@staticmethod
@ -301,6 +339,7 @@ class WebDAVXML:
@staticmethod
def create_multistatus() -> ET.Element:
"""Create multistatus response root"""
WebDAVXML.register_namespaces()
return ET.Element('{DAV:}multistatus')
@staticmethod
@ -317,15 +356,38 @@ class WebDAVXML:
propstat = ET.SubElement(response, '{DAV:}propstat')
prop = ET.SubElement(propstat, '{DAV:}prop')
for prop_name, prop_value in props.items():
if ':' in prop_name:
ns, name = prop_name.split(':', 1)
ns_uri = WebDAVXML.NS.get(ns, ns)
prop_elem = ET.SubElement(prop, f'{{{ns_uri}}}{name}')
else:
prop_elem = ET.SubElement(prop, f'{{DAV:}}{prop_name}')
is_collection = props.pop('_is_collection', False)
if prop_value is not None:
# Standard DAV properties - these should be in DAV: namespace
standard_props = {
'resourcetype', 'creationdate', 'getlastmodified',
'getcontentlength', 'getcontenttype', 'displayname',
'getetag', 'lockdiscovery', 'supportedlock'
}
for prop_name, prop_value in props.items():
# Check if property is already in {namespace}name format
if prop_name.startswith('{'):
# Already has namespace in Clark notation
prop_elem = ET.SubElement(prop, prop_name)
# Handle colon-separated namespace prefix (e.g., "D:displayname")
elif ':' in prop_name and not prop_name.startswith('http'):
ns_prefix, name = prop_name.split(':', 1)
ns_uri = WebDAVXML.NS.get(ns_prefix, ns_prefix)
prop_elem = ET.SubElement(prop, f'{{{ns_uri}}}{name}')
# Standard DAV properties
elif prop_name.lower() in standard_props:
prop_elem = ET.SubElement(prop, f'{{DAV:}}{prop_name}')
# Custom properties without explicit namespace - use custom namespace
else:
prop_elem = ET.SubElement(prop, f'{{http://webdav.org/custom/}}{prop_name}')
# Special handling for resourcetype
if prop_name == 'resourcetype' or prop_name == '{DAV:}resourcetype':
if is_collection:
ET.SubElement(prop_elem, '{DAV:}collection')
# Empty for non-collections
elif prop_value is not None:
prop_elem.text = str(prop_value)
status_elem = ET.SubElement(propstat, '{DAV:}status')
@ -334,6 +396,8 @@ class WebDAVXML:
@staticmethod
def serialize(element: ET.Element) -> str:
"""Serialize XML element to string"""
# Register all namespaces before serializing
WebDAVXML.register_namespaces()
return ET.tostring(element, encoding='unicode', method='xml')
@staticmethod
@ -521,7 +585,7 @@ class WebDAVHandler:
return web.Response(
status=200,
headers={
'DAV': '1, 2, 3',
'DAV': '1, 2',
'MS-Author-Via': 'DAV',
'Allow': 'OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK',
'Access-Control-Allow-Methods': 'OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK',
@ -663,7 +727,10 @@ class WebDAVHandler:
if depth_header == 'infinity':
depth = Config.MAX_PROPFIND_DEPTH
else:
try:
depth = int(depth_header)
except ValueError:
depth = 1
# Parse request body
body = await request.read()
@ -675,15 +742,16 @@ class WebDAVHandler:
# Add resources
await self.add_resource_props(multistatus, path, request.path, user, depth, prop_request)
xml_response = WebDAVXML.serialize(multistatus)
xml_response = '<?xml version="1.0" encoding="utf-8"?>\n' + WebDAVXML.serialize(multistatus)
return web.Response(
status=207,
content_type='application/xml',
charset='utf-8',
text='<?xml version="1.0" encoding="utf-8"?>\n' + xml_response,
text=xml_response,
headers={
'DAV': '1, 2, 3',
'DAV': '1, 2',
'MS-Author-Via': 'DAV',
}
)
@ -718,14 +786,16 @@ class WebDAVHandler:
stat = path.stat()
# Resource type
# Resource type - use None for proper XML nesting
if path.is_dir():
props['resourcetype'] = '<D:collection/>'
props['resourcetype'] = None # Will be handled specially
props['_is_collection'] = True
else:
props['resourcetype'] = ''
props['resourcetype'] = None
props['_is_collection'] = False
# Creation date
props['creationdate'] = datetime.fromtimestamp(stat.st_ctime).isoformat() + 'Z'
props['creationdate'] = datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%dT%H:%M:%SZ')
# Last modified
props['getlastmodified'] = datetime.fromtimestamp(stat.st_mtime).strftime('%a, %d %b %Y %H:%M:%S GMT')
@ -745,7 +815,28 @@ class WebDAVHandler:
# Get custom properties from database
custom_props = await self.db.get_properties(str(path))
for prop in custom_props:
key = f"{prop['namespace']}:{prop['property_name']}" if prop['namespace'] else prop['property_name']
# Handle properties with full namespace URIs in the name
prop_name = prop['property_name']
namespace = prop['namespace'] or ''
# Check if property name contains a colon (namespace separator)
# This happens when cadaver stores properties
if ':' in prop_name and not namespace:
# Extract namespace from property name
# e.g., "http://webdav.org/cadaver/custom-properties/:xxx"
# Split only on the LAST colon to get the actual property name
parts = prop_name.rsplit(':', 1)
if len(parts) == 2:
namespace = parts[0] + ':'
prop_name = parts[1]
# Create property key with namespace
if namespace:
# Store as full namespace URI + property name for ElementTree
key = f'{{{namespace}}}{prop_name}'
else:
key = prop_name
props[key] = prop['property_value']
return props
@ -780,16 +871,25 @@ class WebDAVHandler:
await self.db.set_property(str(path), namespace, prop_name, prop_value)
# Process remove operations
for remove_elem in root.findall('.//{DAV:}remove/{DAV:}prop'):
for prop_elem in remove_elem:
namespace = prop_elem.tag.split('}')[0].strip('{') if '}' in prop_elem.tag else ''
prop_name = prop_elem.tag.split('}')[1] if '}' in prop_elem.tag else prop_elem.tag
# Remove property using database method
await self.db.remove_property(str(path), namespace, prop_name)
WebDAVXML.add_propstat(response, {}, '200 OK')
multistatus.append(response)
xml_response = WebDAVXML.serialize(multistatus)
xml_response = '<?xml version="1.0" encoding="utf-8"?>\n' + WebDAVXML.serialize(multistatus)
return web.Response(
status=207,
content_type='application/xml',
charset='utf-8',
text='<?xml version="1.0" encoding="utf-8"?>\n' + xml_response
text=xml_response
)
except Exception as e:

BIN
webdav.db

Binary file not shown.

10
webdav_cli.py Normal file → Executable file
View File

@ -288,6 +288,16 @@ class WebDAVCLI:
print("\n" + "=" * 60)
async def clear_cache(self):
"""Clear authentication cache"""
print("⚠️ This requires the server to be running with cache management enabled.")
print("Cache statistics are only available via server admin interface.")
print("\nTo clear cache:")
print("1. Stop the server")
print("2. Restart the server (cache is in-memory only)")
print("\nOr use the admin API if enabled:")
print(" curl -X POST http://localhost:8080/admin/cache/clear")
# ========================================================================
# Database Management
# ========================================================================