diff --git a/.gitignore b/.gitignore index 4341520..cab78ad 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ .env __pycache__/ logs/ +webdav +debug_webdav.py +test_propfind.sh +test_cache.py +*.db diff --git a/main.py b/main.py index 0e9b17d..4dfed8a 100644 --- a/main.py +++ b/main.py @@ -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,15 +178,20 @@ class Database: salt = secrets.token_hex(16) password_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000).hex() - conn = self.get_connection() - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO users (username, email, password_hash, salt, role) - VALUES (?, ?, ?, ?, ?) - ''', (username, email, password_hash, salt, role)) - user_id = cursor.lastrowid - conn.commit() - conn.close() + def _create(): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO users (username, email, password_hash, salt, role) + VALUES (?, ?, ?, ?, ?) + ''', (username, email, password_hash, salt, role)) + 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') + is_collection = props.pop('_is_collection', False) + + # 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(): - if ':' in prop_name: - ns, name = prop_name.split(':', 1) - ns_uri = WebDAVXML.NS.get(ns, ns) + # 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}') - else: + # 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}') - if prop_value is not None: + # 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: - depth = int(depth_header) + 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 = '\n' + WebDAVXML.serialize(multistatus) return web.Response( status=207, content_type='application/xml', charset='utf-8', - text='\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'] = '' + 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 = '\n' + WebDAVXML.serialize(multistatus) return web.Response( status=207, content_type='application/xml', charset='utf-8', - text='\n' + xml_response + text=xml_response ) except Exception as e: diff --git a/webdav.db b/webdav.db deleted file mode 100644 index eb38bc4..0000000 Binary files a/webdav.db and /dev/null differ diff --git a/webdav_cli.py b/webdav_cli.py old mode 100644 new mode 100755 index e1ddcfc..6e4d973 --- a/webdav_cli.py +++ b/webdav_cli.py @@ -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 # ========================================================================