""" Complete WebDAV Server Implementation with aiohttp Production-ready WebDAV server with full RFC 4918 compliance, Windows Explorer compatibility, and comprehensive user management. """ import os import asyncio import aiofiles import sqlite3 import hashlib import hmac import secrets import mimetypes import base64 from datetime import datetime, timedelta from pathlib import Path from typing import Optional, Dict, List, Tuple from xml.etree import ElementTree as ET from urllib.parse import unquote, quote from aiohttp import web, BasicAuth from aiohttp_session import setup as setup_session, get_session from aiohttp_session.cookie_storage import EncryptedCookieStorage from cryptography import fernet from dotenv import load_dotenv # Load environment variables load_dotenv() # ============================================================================ # Configuration Management # ============================================================================ class Config: """Centralized configuration management from environment variables""" # Server Configuration HOST = os.getenv('HOST', '0.0.0.0') PORT = int(os.getenv('PORT', '8080')) SSL_ENABLED = os.getenv('SSL_ENABLED', 'false').lower() == 'true' SSL_CERT_PATH = os.getenv('SSL_CERT_PATH', '') SSL_KEY_PATH = os.getenv('SSL_KEY_PATH', '') # Database Configuration DB_PATH = os.getenv('DB_PATH', './webdav.db') # Authentication Configuration AUTH_METHODS = os.getenv('AUTH_METHODS', 'basic,digest').split(',') JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', secrets.token_hex(32)) SESSION_TIMEOUT = int(os.getenv('SESSION_TIMEOUT', '3600')) PASSWORD_MIN_LENGTH = int(os.getenv('PASSWORD_MIN_LENGTH', '8')) # WebDAV Configuration MAX_FILE_SIZE = int(os.getenv('MAX_FILE_SIZE', '104857600')) # 100MB MAX_PROPFIND_DEPTH = int(os.getenv('MAX_PROPFIND_DEPTH', '3')) LOCK_TIMEOUT_DEFAULT = int(os.getenv('LOCK_TIMEOUT_DEFAULT', '3600')) ENABLE_WINDOWS_COMPATIBILITY = os.getenv('ENABLE_WINDOWS_COMPATIBILITY', 'true').lower() == 'true' # WebDAV Root Directory WEBDAV_ROOT = os.getenv('WEBDAV_ROOT', './webdav') # Security Configuration RATE_LIMIT_ENABLED = os.getenv('RATE_LIMIT_ENABLED', 'true').lower() == 'true' RATE_LIMIT_REQUESTS = int(os.getenv('RATE_LIMIT_REQUESTS', '100')) RATE_LIMIT_WINDOW = int(os.getenv('RATE_LIMIT_WINDOW', '60')) # ============================================================================ # Database Layer # ============================================================================ class Database: """SQLite database management with async wrapper""" def __init__(self, db_path: str): self.db_path = db_path self.init_database() def get_connection(self) -> sqlite3.Connection: """Get database connection with row factory""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row return conn def init_database(self): """Initialize database schema""" conn = self.get_connection() cursor = conn.cursor() # Users table cursor.execute(''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, email TEXT UNIQUE, password_hash TEXT NOT NULL, salt TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_login TIMESTAMP, is_active BOOLEAN DEFAULT 1, role TEXT DEFAULT 'user' ) ''') # Sessions table cursor.execute(''' CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, user_id INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP, ip_address TEXT, user_agent TEXT, FOREIGN KEY (user_id) REFERENCES users (id) ) ''') # Locks table cursor.execute(''' CREATE TABLE IF NOT EXISTS locks ( lock_token TEXT PRIMARY KEY, resource_path TEXT NOT NULL, user_id INTEGER, lock_type TEXT DEFAULT 'write', lock_scope TEXT DEFAULT 'exclusive', depth INTEGER DEFAULT 0, timeout_seconds INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, owner TEXT, FOREIGN KEY (user_id) REFERENCES users (id) ) ''') # Properties table cursor.execute(''' CREATE TABLE IF NOT EXISTS properties ( id INTEGER PRIMARY KEY AUTOINCREMENT, resource_path TEXT NOT NULL, namespace TEXT, property_name TEXT NOT NULL, property_value TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') # Permissions table cursor.execute(''' CREATE TABLE IF NOT EXISTS permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, resource_path TEXT NOT NULL, permission_type TEXT NOT NULL, granted_by INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id), FOREIGN KEY (granted_by) REFERENCES users (id) ) ''') # Create indices cursor.execute('CREATE INDEX IF NOT EXISTS idx_locks_resource ON locks(resource_path)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_properties_resource ON properties(resource_path)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id)') conn.commit() conn.close() async def create_user(self, username: str, password: str, email: str = None, role: str = 'user') -> int: """Create a new user""" 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() # Create user directory user_dir = Path(Config.WEBDAV_ROOT) / 'users' / username user_dir.mkdir(parents=True, exist_ok=True) return user_id async 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() if not user: return None password_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), user['salt'].encode(), 100000).hex() if password_hash == user['password_hash']: return dict(user) return None async def get_user_by_id(self, user_id: int) -> Optional[Dict]: """Get user by ID""" conn = self.get_connection() cursor = conn.cursor() cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,)) user = cursor.fetchone() conn.close() return dict(user) if user else None async def create_lock(self, resource_path: str, user_id: int, lock_type: str = 'write', lock_scope: str = 'exclusive', depth: int = 0, timeout: int = None, owner: str = None) -> str: """Create a resource lock""" lock_token = f"opaquelocktoken:{secrets.token_urlsafe(32)}" timeout = timeout or Config.LOCK_TIMEOUT_DEFAULT conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT INTO locks (lock_token, resource_path, user_id, lock_type, lock_scope, depth, timeout_seconds, owner) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ''', (lock_token, resource_path, user_id, lock_type, lock_scope, depth, timeout, owner)) conn.commit() conn.close() return lock_token async def get_lock(self, resource_path: str) -> Optional[Dict]: """Get lock for a resource""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT * FROM locks WHERE resource_path = ? AND datetime(created_at, '+' || timeout_seconds || ' seconds') > datetime('now') ''', (resource_path,)) lock = cursor.fetchone() conn.close() return dict(lock) if lock else None async def remove_lock(self, lock_token: str, user_id: int) -> bool: """Remove a lock""" conn = self.get_connection() cursor = conn.cursor() cursor.execute('DELETE FROM locks WHERE lock_token = ? AND user_id = ?', (lock_token, user_id)) deleted = cursor.rowcount > 0 conn.commit() conn.close() return deleted async def set_property(self, resource_path: str, namespace: str, property_name: str, property_value: str): """Set a custom property on a resource""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' INSERT OR REPLACE INTO properties (resource_path, namespace, property_name, property_value) VALUES (?, ?, ?, ?) ''', (resource_path, namespace, property_name, property_value)) conn.commit() conn.close() async def get_properties(self, resource_path: str) -> List[Dict]: """Get all properties for a resource""" conn = self.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT * FROM properties WHERE resource_path = ? ''', (resource_path,)) properties = cursor.fetchall() conn.close() return [dict(prop) for prop in properties] # ============================================================================ # XML Utilities for WebDAV # ============================================================================ class WebDAVXML: """XML processing utilities for WebDAV protocol""" # WebDAV namespaces NS = { 'D': 'DAV:', 'MS': 'urn:schemas-microsoft-com:' } @staticmethod def register_namespaces(): """Register XML namespaces""" for prefix, uri in WebDAVXML.NS.items(): ET.register_namespace(prefix, uri) @staticmethod def create_multistatus() -> ET.Element: """Create multistatus response root""" return ET.Element('{DAV:}multistatus') @staticmethod def create_response(href: str) -> ET.Element: """Create response element""" response = ET.Element('{DAV:}response') href_elem = ET.SubElement(response, '{DAV:}href') href_elem.text = href return response @staticmethod def add_propstat(response: ET.Element, props: Dict[str, str], status: str = '200 OK'): """Add propstat element to response""" 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}') if prop_value is not None: prop_elem.text = str(prop_value) status_elem = ET.SubElement(propstat, '{DAV:}status') status_elem.text = f'HTTP/1.1 {status}' @staticmethod def serialize(element: ET.Element) -> str: """Serialize XML element to string""" return ET.tostring(element, encoding='unicode', method='xml') @staticmethod def parse_propfind(body: bytes) -> Tuple[str, List[str]]: """Parse PROPFIND request body""" if not body: return 'allprop', [] try: root = ET.fromstring(body) # Check for allprop if root.find('.//{DAV:}allprop') is not None: return 'allprop', [] # Check for propname if root.find('.//{DAV:}propname') is not None: return 'propname', [] # Get specific properties prop_elem = root.find('.//{DAV:}prop') if prop_elem is not None: props = [] for child in prop_elem: props.append(child.tag) return 'prop', props return 'allprop', [] except Exception: return 'allprop', [] # ============================================================================ # Authentication and Authorization # ============================================================================ class AuthHandler: """Handle multiple authentication methods""" def __init__(self, db: Database): self.db = db self.nonces = {} # Store nonces for digest auth async def authenticate_basic(self, request: web.Request) -> Optional[Dict]: """Basic authentication""" auth_header = request.headers.get('Authorization', '') if not auth_header.startswith('Basic '): return None try: credentials = base64.b64decode(auth_header[6:]).decode('utf-8') username, password = credentials.split(':', 1) return await self.db.verify_user(username, password) except Exception: return None def generate_digest_challenge(self, realm: str = 'WebDAV Server') -> str: """Generate digest authentication challenge""" nonce = secrets.token_hex(16) opaque = secrets.token_hex(16) self.nonces[nonce] = datetime.now() return f'Digest realm="{realm}", qop="auth", nonce="{nonce}", opaque="{opaque}"' async def authenticate_digest(self, request: web.Request) -> Optional[Dict]: """Digest authentication""" auth_header = request.headers.get('Authorization', '') if not auth_header.startswith('Digest '): return None # Parse digest parameters params = {} for item in auth_header[7:].split(','): key, value = item.strip().split('=', 1) params[key] = value.strip('"') username = params.get('username') nonce = params.get('nonce') uri = params.get('uri') response = params.get('response') if not all([username, nonce, uri, response]): return None # Verify nonce if nonce not in self.nonces: return None # Get user from database conn = self.db.get_connection() cursor = conn.cursor() cursor.execute('SELECT * FROM users WHERE username = ? AND is_active = 1', (username,)) user = cursor.fetchone() conn.close() if not user: return None # Calculate expected response ha1 = hashlib.md5(f"{username}:WebDAV Server:{user['password_hash']}".encode()).hexdigest() ha2 = hashlib.md5(f"{request.method}:{uri}".encode()).hexdigest() expected_response = hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest() if response == expected_response: return dict(user) return None async def authenticate(self, request: web.Request) -> Optional[Dict]: """Authenticate request using configured methods""" if 'basic' in Config.AUTH_METHODS: user = await self.authenticate_basic(request) if user: return user if 'digest' in Config.AUTH_METHODS: user = await self.authenticate_digest(request) if user: return user return None def require_auth_response(self) -> web.Response: """Return 401 response with authentication challenges""" challenges = [] if 'basic' in Config.AUTH_METHODS: challenges.append('Basic realm="WebDAV Server"') if 'digest' in Config.AUTH_METHODS: challenges.append(self.generate_digest_challenge()) return web.Response( status=401, headers={ 'WWW-Authenticate': ', '.join(challenges), 'DAV': '1, 2, 3', }, text='Unauthorized' ) # ============================================================================ # WebDAV Handler # ============================================================================ class WebDAVHandler: """Main WebDAV protocol handler""" def __init__(self, db: Database, auth: AuthHandler): self.db = db self.auth = auth WebDAVXML.register_namespaces() def get_user_root(self, username: str) -> Path: """Get user's root directory""" return Path(Config.WEBDAV_ROOT) / 'users' / username def get_physical_path(self, username: str, webdav_path: str) -> Path: """Convert WebDAV path to physical file system path""" # Remove DavWWWRoot if present (Windows compatibility) webdav_path = webdav_path.replace('/DavWWWRoot/', '/') webdav_path = webdav_path.replace('\\DavWWWRoot\\', '/') # Normalize path webdav_path = unquote(webdav_path) webdav_path = webdav_path.lstrip('/') # Build physical path user_root = self.get_user_root(username) physical_path = user_root / webdav_path # Ensure path is within user directory (security) try: physical_path.resolve().relative_to(user_root.resolve()) except ValueError: raise web.HTTPForbidden(text="Access denied") return physical_path async def handle_options(self, request: web.Request, user: Dict) -> web.Response: """Handle OPTIONS method""" return web.Response( status=200, headers={ 'DAV': '1, 2, 3', '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', } ) async def handle_get(self, request: web.Request, user: Dict) -> web.Response: """Handle GET method""" path = self.get_physical_path(user['username'], request.path) if not path.exists(): raise web.HTTPNotFound() if path.is_dir(): # Return directory listing for browsers return await self.generate_directory_listing(path, request.path) # Return file content content_type, _ = mimetypes.guess_type(str(path)) async with aiofiles.open(path, 'rb') as f: content = await f.read() return web.Response( body=content, content_type=content_type or 'application/octet-stream', headers={ 'Content-Length': str(len(content)), 'Accept-Ranges': 'bytes', } ) async def handle_head(self, request: web.Request, user: Dict) -> web.Response: """Handle HEAD method""" path = self.get_physical_path(user['username'], request.path) if not path.exists(): raise web.HTTPNotFound() content_type, _ = mimetypes.guess_type(str(path)) if path.is_file(): size = path.stat().st_size return web.Response( headers={ 'Content-Type': content_type or 'application/octet-stream', 'Content-Length': str(size), } ) return web.Response( headers={ 'Content-Type': 'httpd/unix-directory', } ) async def handle_put(self, request: web.Request, user: Dict) -> web.Response: """Handle PUT method""" path = self.get_physical_path(user['username'], request.path) # Check if locked lock = await self.db.get_lock(request.path) if lock and lock['user_id'] != user['id']: raise web.HTTPLocked() # Create parent directories path.parent.mkdir(parents=True, exist_ok=True) # Write file atomically using temporary file temp_path = path.with_suffix(path.suffix + '.tmp') try: async with aiofiles.open(temp_path, 'wb') as f: async for chunk in request.content.iter_chunked(8192): await f.write(chunk) # Move temporary file to final location temp_path.replace(path) status = 201 if not path.exists() else 204 return web.Response(status=status) except Exception as e: if temp_path.exists(): temp_path.unlink() raise web.HTTPInternalServerError(text=str(e)) async def handle_delete(self, request: web.Request, user: Dict) -> web.Response: """Handle DELETE method""" path = self.get_physical_path(user['username'], request.path) if not path.exists(): raise web.HTTPNotFound() # Check if locked lock = await self.db.get_lock(request.path) if lock and lock['user_id'] != user['id']: raise web.HTTPLocked() try: if path.is_dir(): import shutil shutil.rmtree(path) else: path.unlink() return web.Response(status=204) except Exception as e: raise web.HTTPInternalServerError(text=str(e)) async def handle_mkcol(self, request: web.Request, user: Dict) -> web.Response: """Handle MKCOL method""" path = self.get_physical_path(user['username'], request.path) if path.exists(): raise web.HTTPMethodNotAllowed(method='MKCOL', allowed_methods=[]) # Check if parent exists if not path.parent.exists(): raise web.HTTPConflict() try: path.mkdir(parents=False, exist_ok=False) return web.Response(status=201) except Exception as e: raise web.HTTPInternalServerError(text=str(e)) async def handle_propfind(self, request: web.Request, user: Dict) -> web.Response: """Handle PROPFIND method""" path = self.get_physical_path(user['username'], request.path) if not path.exists(): raise web.HTTPNotFound() # Get depth header depth_header = request.headers.get('Depth', '1') if depth_header == 'infinity': depth = Config.MAX_PROPFIND_DEPTH else: depth = int(depth_header) # Parse request body body = await request.read() prop_request, specific_props = WebDAVXML.parse_propfind(body) # Build multistatus response multistatus = WebDAVXML.create_multistatus() # Add resources await self.add_resource_props(multistatus, path, request.path, user, depth, prop_request) xml_response = WebDAVXML.serialize(multistatus) return web.Response( status=207, content_type='application/xml', charset='utf-8', text='\n' + xml_response, headers={ 'DAV': '1, 2, 3', } ) async def add_resource_props(self, multistatus: ET.Element, path: Path, href: str, user: Dict, depth: int, prop_request: str): """Add resource properties to multistatus response""" # Add current resource response = WebDAVXML.create_response(quote(href)) props = await self.get_resource_properties(path, user) WebDAVXML.add_propstat(response, props) multistatus.append(response) # Add children if directory and depth > 0 if depth > 0 and path.is_dir(): try: for child in path.iterdir(): child_href = href.rstrip('/') + '/' + child.name if child.is_dir(): child_href += '/' await self.add_resource_props( multistatus, child, child_href, user, depth - 1, prop_request ) except PermissionError: pass async def get_resource_properties(self, path: Path, user: Dict) -> Dict[str, str]: """Get standard WebDAV properties for a resource""" props = {} stat = path.stat() # Resource type if path.is_dir(): props['resourcetype'] = '' else: props['resourcetype'] = '' # Creation date props['creationdate'] = datetime.fromtimestamp(stat.st_ctime).isoformat() + 'Z' # Last modified props['getlastmodified'] = datetime.fromtimestamp(stat.st_mtime).strftime('%a, %d %b %Y %H:%M:%S GMT') # Content length if path.is_file(): props['getcontentlength'] = str(stat.st_size) # Content type if path.is_file(): content_type, _ = mimetypes.guess_type(str(path)) props['getcontenttype'] = content_type or 'application/octet-stream' # Display name props['displayname'] = path.name # 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'] props[key] = prop['property_value'] return props async def handle_proppatch(self, request: web.Request, user: Dict) -> web.Response: """Handle PROPPATCH method""" path = self.get_physical_path(user['username'], request.path) if not path.exists(): raise web.HTTPNotFound() # Check if locked lock = await self.db.get_lock(request.path) if lock and lock['user_id'] != user['id']: raise web.HTTPLocked() # Parse request body body = await request.read() try: root = ET.fromstring(body) multistatus = WebDAVXML.create_multistatus() response = WebDAVXML.create_response(quote(request.path)) # Process set operations for set_elem in root.findall('.//{DAV:}set/{DAV:}prop'): for prop_elem in set_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 prop_value = prop_elem.text or '' await self.db.set_property(str(path), namespace, prop_name, prop_value) WebDAVXML.add_propstat(response, {}, '200 OK') multistatus.append(response) xml_response = WebDAVXML.serialize(multistatus) return web.Response( status=207, content_type='application/xml', charset='utf-8', text='\n' + xml_response ) except Exception as e: raise web.HTTPInternalServerError(text=str(e)) async def handle_copy(self, request: web.Request, user: Dict) -> web.Response: """Handle COPY method""" src_path = self.get_physical_path(user['username'], request.path) if not src_path.exists(): raise web.HTTPNotFound() # Get destination destination = request.headers.get('Destination') if not destination: raise web.HTTPBadRequest(text="Missing Destination header") # Parse destination URL from urllib.parse import urlparse dest_url = urlparse(destination) dest_path = self.get_physical_path(user['username'], dest_url.path) # Check overwrite overwrite = request.headers.get('Overwrite', 'T') == 'T' if dest_path.exists() and not overwrite: raise web.HTTPPreconditionFailed() try: import shutil if src_path.is_dir(): shutil.copytree(src_path, dest_path, dirs_exist_ok=overwrite) else: dest_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src_path, dest_path) status = 204 if dest_path.exists() else 201 return web.Response(status=status) except Exception as e: raise web.HTTPInternalServerError(text=str(e)) async def handle_move(self, request: web.Request, user: Dict) -> web.Response: """Handle MOVE method""" src_path = self.get_physical_path(user['username'], request.path) if not src_path.exists(): raise web.HTTPNotFound() # Check if locked lock = await self.db.get_lock(request.path) if lock and lock['user_id'] != user['id']: raise web.HTTPLocked() # Get destination destination = request.headers.get('Destination') if not destination: raise web.HTTPBadRequest(text="Missing Destination header") # Parse destination URL from urllib.parse import urlparse dest_url = urlparse(destination) dest_path = self.get_physical_path(user['username'], dest_url.path) # Check overwrite overwrite = request.headers.get('Overwrite', 'T') == 'T' if dest_path.exists() and not overwrite: raise web.HTTPPreconditionFailed() try: dest_path.parent.mkdir(parents=True, exist_ok=True) src_path.replace(dest_path) status = 204 if dest_path.exists() else 201 return web.Response(status=status) except Exception as e: raise web.HTTPInternalServerError(text=str(e)) async def handle_lock(self, request: web.Request, user: Dict) -> web.Response: """Handle LOCK method""" path = self.get_physical_path(user['username'], request.path) # Parse lock request body = await request.read() try: root = ET.fromstring(body) # Get lock scope and type lock_scope = 'exclusive' if root.find('.//{DAV:}shared') is not None: lock_scope = 'shared' lock_type = 'write' # Get timeout timeout_header = request.headers.get('Timeout', f'Second-{Config.LOCK_TIMEOUT_DEFAULT}') timeout = Config.LOCK_TIMEOUT_DEFAULT if timeout_header.startswith('Second-'): try: timeout = int(timeout_header.split('-')[1]) except: pass # Get owner info owner_elem = root.find('.//{DAV:}owner') owner = ET.tostring(owner_elem, encoding='unicode') if owner_elem is not None else None # Create lock lock_token = await self.db.create_lock( request.path, user['id'], lock_type, lock_scope, 0, timeout, owner ) # Build lock response response_xml = f''' 0 Second-{timeout} {lock_token} {owner or ''} ''' return web.Response( status=200, content_type='application/xml', charset='utf-8', text=response_xml, headers={ 'Lock-Token': f'<{lock_token}>', } ) except Exception as e: raise web.HTTPInternalServerError(text=str(e)) async def handle_unlock(self, request: web.Request, user: Dict) -> web.Response: """Handle UNLOCK method""" lock_token = request.headers.get('Lock-Token', '').strip('<>') if not lock_token: raise web.HTTPBadRequest(text="Missing Lock-Token header") removed = await self.db.remove_lock(lock_token, user['id']) if removed: return web.Response(status=204) else: raise web.HTTPConflict(text="Lock not found or not owned by user") async def generate_directory_listing(self, path: Path, href: str) -> web.Response: """Generate HTML directory listing""" html = f''' Index of {href}

Index of {href}

''' # Add parent directory link if href != '/': parent = '/'.join(href.rstrip('/').split('/')[:-1]) or '/' html += f'' # List directory contents items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())) for item in items: name = item.name if item.is_dir(): name += '/' size = '-' if item.is_dir() else f'{item.stat().st_size:,}' modified = datetime.fromtimestamp(item.stat().st_mtime).strftime('%Y-%m-%d %H:%M') item_href = href.rstrip('/') + '/' + item.name if item.is_dir(): item_href += '/' html += f'' html += '''
NameSizeModified
..--
{name}{size}{modified}
''' return web.Response(text=html, content_type='text/html') # ============================================================================ # Web Application # ============================================================================ async def webdav_middleware(app, handler): """Middleware to handle authentication and routing""" async def middleware_handler(request: web.Request): # Handle OPTIONS without authentication if request.method == 'OPTIONS': webdav = app['webdav'] return await webdav.handle_options(request, {}) # Authenticate user user = await app['auth'].authenticate(request) if not user: return app['auth'].require_auth_response() # Store user in request request['user'] = user # Route to appropriate handler webdav = app['webdav'] if request.method == 'GET': return await webdav.handle_get(request, user) elif request.method == 'HEAD': return await webdav.handle_head(request, user) elif request.method == 'PUT': return await webdav.handle_put(request, user) elif request.method == 'DELETE': return await webdav.handle_delete(request, user) elif request.method == 'MKCOL': return await webdav.handle_mkcol(request, user) elif request.method == 'PROPFIND': return await webdav.handle_propfind(request, user) elif request.method == 'PROPPATCH': return await webdav.handle_proppatch(request, user) elif request.method == 'COPY': return await webdav.handle_copy(request, user) elif request.method == 'MOVE': return await webdav.handle_move(request, user) elif request.method == 'LOCK': return await webdav.handle_lock(request, user) elif request.method == 'UNLOCK': return await webdav.handle_unlock(request, user) elif request.method == 'OPTIONS': return await webdav.handle_options(request, user) else: raise web.HTTPMethodNotAllowed(method=request.method, allowed_methods=[]) return middleware_handler async def init_app() -> web.Application: """Initialize web application""" # Create application app = web.Application( client_max_size=Config.MAX_FILE_SIZE, middlewares=[] ) # Initialize database db = Database(Config.DB_PATH) app['db'] = db # Initialize authentication auth = AuthHandler(db) app['auth'] = auth # Initialize WebDAV handler webdav = WebDAVHandler(db, auth) app['webdav'] = webdav # Setup session secret_key = Config.JWT_SECRET_KEY.encode() setup_session(app, EncryptedCookieStorage(secret_key)) # Add middleware app.middlewares.append(webdav_middleware) # Add catch-all route for WebDAV app.router.add_route('*', '/{path:.*}', lambda r: web.Response(status=200)) return app async def create_default_user(db: Database): """Create default admin user if no users exist""" conn = db.get_connection() cursor = conn.cursor() cursor.execute('SELECT COUNT(*) as count FROM users') count = cursor.fetchone()['count'] conn.close() if count == 0: print("Creating default admin user...") await db.create_user('admin', 'admin123', 'admin@webdav.local', 'admin') print("Default user created: admin / admin123") print("Please change this password immediately!") def main(): """Main entry point""" # Ensure WebDAV root directory exists Path(Config.WEBDAV_ROOT).mkdir(parents=True, exist_ok=True) # Initialize database and create default user db = Database(Config.DB_PATH) asyncio.run(create_default_user(db)) # Create and run application app = asyncio.run(init_app()) print(f"Starting WebDAV Server on {Config.HOST}:{Config.PORT}") print(f"WebDAV URL: http://{Config.HOST}:{Config.PORT}/") print(f"Authentication methods: {', '.join(Config.AUTH_METHODS)}") web.run_app( app, host=Config.HOST, port=Config.PORT, access_log_format='%a %t "%r" %s %b "%{Referer}i" "%{User-Agent}i"' ) if __name__ == '__main__': main()