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 .env
__pycache__/ __pycache__/
logs/ 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 secrets
import mimetypes import mimetypes
import base64 import base64
import functools
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple
@ -75,12 +76,17 @@ class Database:
def __init__(self, db_path: str): def __init__(self, db_path: str):
self.db_path = db_path self.db_path = db_path
self._connection_lock = asyncio.Lock()
self.init_database() self.init_database()
def get_connection(self) -> sqlite3.Connection: def get_connection(self) -> sqlite3.Connection:
"""Get database connection with row factory""" """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 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 return conn
def init_database(self): def init_database(self):
@ -172,6 +178,7 @@ class Database:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
password_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000).hex() password_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000).hex()
def _create():
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
@ -181,6 +188,10 @@ class Database:
user_id = cursor.lastrowid user_id = cursor.lastrowid
conn.commit() conn.commit()
conn.close() 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 # Create user directory
user_dir = Path(Config.WEBDAV_ROOT) / 'users' / username user_dir = Path(Config.WEBDAV_ROOT) / 'users' / username
@ -188,7 +199,17 @@ class Database:
return user_id 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""" """Verify user credentials"""
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -205,6 +226,10 @@ class Database:
return dict(user) return dict(user)
return None 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]: async def get_user_by_id(self, user_id: int) -> Optional[Dict]:
"""Get user by ID""" """Get user by ID"""
conn = self.get_connection() conn = self.get_connection()
@ -267,6 +292,18 @@ class Database:
conn.commit() conn.commit()
conn.close() 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]: async def get_properties(self, resource_path: str) -> List[Dict]:
"""Get all properties for a resource""" """Get all properties for a resource"""
conn = self.get_connection() conn = self.get_connection()
@ -289,7 +326,8 @@ class WebDAVXML:
# WebDAV namespaces # WebDAV namespaces
NS = { NS = {
'D': 'DAV:', 'D': 'DAV:',
'MS': 'urn:schemas-microsoft-com:' 'MS': 'urn:schemas-microsoft-com:',
'C': 'http://webdav.org/custom/' # Custom properties namespace
} }
@staticmethod @staticmethod
@ -301,6 +339,7 @@ class WebDAVXML:
@staticmethod @staticmethod
def create_multistatus() -> ET.Element: def create_multistatus() -> ET.Element:
"""Create multistatus response root""" """Create multistatus response root"""
WebDAVXML.register_namespaces()
return ET.Element('{DAV:}multistatus') return ET.Element('{DAV:}multistatus')
@staticmethod @staticmethod
@ -317,15 +356,38 @@ class WebDAVXML:
propstat = ET.SubElement(response, '{DAV:}propstat') propstat = ET.SubElement(response, '{DAV:}propstat')
prop = ET.SubElement(propstat, '{DAV:}prop') prop = ET.SubElement(propstat, '{DAV:}prop')
for prop_name, prop_value in props.items(): is_collection = props.pop('_is_collection', False)
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}')
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) prop_elem.text = str(prop_value)
status_elem = ET.SubElement(propstat, '{DAV:}status') status_elem = ET.SubElement(propstat, '{DAV:}status')
@ -334,6 +396,8 @@ class WebDAVXML:
@staticmethod @staticmethod
def serialize(element: ET.Element) -> str: def serialize(element: ET.Element) -> str:
"""Serialize XML element to string""" """Serialize XML element to string"""
# Register all namespaces before serializing
WebDAVXML.register_namespaces()
return ET.tostring(element, encoding='unicode', method='xml') return ET.tostring(element, encoding='unicode', method='xml')
@staticmethod @staticmethod
@ -521,7 +585,7 @@ class WebDAVHandler:
return web.Response( return web.Response(
status=200, status=200,
headers={ headers={
'DAV': '1, 2, 3', 'DAV': '1, 2',
'MS-Author-Via': 'DAV', 'MS-Author-Via': 'DAV',
'Allow': 'OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK', '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', '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': if depth_header == 'infinity':
depth = Config.MAX_PROPFIND_DEPTH depth = Config.MAX_PROPFIND_DEPTH
else: else:
try:
depth = int(depth_header) depth = int(depth_header)
except ValueError:
depth = 1
# Parse request body # Parse request body
body = await request.read() body = await request.read()
@ -675,15 +742,16 @@ class WebDAVHandler:
# Add resources # Add resources
await self.add_resource_props(multistatus, path, request.path, user, depth, prop_request) 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( return web.Response(
status=207, status=207,
content_type='application/xml', content_type='application/xml',
charset='utf-8', charset='utf-8',
text='<?xml version="1.0" encoding="utf-8"?>\n' + xml_response, text=xml_response,
headers={ headers={
'DAV': '1, 2, 3', 'DAV': '1, 2',
'MS-Author-Via': 'DAV',
} }
) )
@ -718,14 +786,16 @@ class WebDAVHandler:
stat = path.stat() stat = path.stat()
# Resource type # Resource type - use None for proper XML nesting
if path.is_dir(): if path.is_dir():
props['resourcetype'] = '<D:collection/>' props['resourcetype'] = None # Will be handled specially
props['_is_collection'] = True
else: else:
props['resourcetype'] = '' props['resourcetype'] = None
props['_is_collection'] = False
# Creation date # 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 # Last modified
props['getlastmodified'] = datetime.fromtimestamp(stat.st_mtime).strftime('%a, %d %b %Y %H:%M:%S GMT') 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 # Get custom properties from database
custom_props = await self.db.get_properties(str(path)) custom_props = await self.db.get_properties(str(path))
for prop in custom_props: 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'] props[key] = prop['property_value']
return props return props
@ -780,16 +871,25 @@ class WebDAVHandler:
await self.db.set_property(str(path), namespace, prop_name, prop_value) 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') WebDAVXML.add_propstat(response, {}, '200 OK')
multistatus.append(response) multistatus.append(response)
xml_response = WebDAVXML.serialize(multistatus) xml_response = '<?xml version="1.0" encoding="utf-8"?>\n' + WebDAVXML.serialize(multistatus)
return web.Response( return web.Response(
status=207, status=207,
content_type='application/xml', content_type='application/xml',
charset='utf-8', charset='utf-8',
text='<?xml version="1.0" encoding="utf-8"?>\n' + xml_response text=xml_response
) )
except Exception as e: 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) 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 # Database Management
# ======================================================================== # ========================================================================