"""
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'] = '
| Name | Size | Modified |
|---|---|---|
| .. | - | - |
| {name} | {size} | {modified} |