Update.
This commit is contained in:
parent
d4aee28247
commit
9a0a6ce0fa
5
.gitignore
vendored
5
.gitignore
vendored
@ -2,3 +2,8 @@
|
|||||||
.env
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
logs/
|
logs/
|
||||||
|
webdav
|
||||||
|
debug_webdav.py
|
||||||
|
test_propfind.sh
|
||||||
|
test_cache.py
|
||||||
|
*.db
|
||||||
|
|||||||
158
main.py
158
main.py
@ -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,15 +178,20 @@ 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()
|
||||||
|
|
||||||
conn = self.get_connection()
|
def _create():
|
||||||
cursor = conn.cursor()
|
conn = self.get_connection()
|
||||||
cursor.execute('''
|
cursor = conn.cursor()
|
||||||
INSERT INTO users (username, email, password_hash, salt, role)
|
cursor.execute('''
|
||||||
VALUES (?, ?, ?, ?, ?)
|
INSERT INTO users (username, email, password_hash, salt, role)
|
||||||
''', (username, email, password_hash, salt, role))
|
VALUES (?, ?, ?, ?, ?)
|
||||||
user_id = cursor.lastrowid
|
''', (username, email, password_hash, salt, role))
|
||||||
conn.commit()
|
user_id = cursor.lastrowid
|
||||||
conn.close()
|
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
|
# 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')
|
||||||
|
|
||||||
|
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():
|
for prop_name, prop_value in props.items():
|
||||||
if ':' in prop_name:
|
# Check if property is already in {namespace}name format
|
||||||
ns, name = prop_name.split(':', 1)
|
if prop_name.startswith('{'):
|
||||||
ns_uri = WebDAVXML.NS.get(ns, ns)
|
# 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}')
|
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}')
|
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)
|
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:
|
||||||
depth = int(depth_header)
|
try:
|
||||||
|
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:
|
||||||
|
|||||||
10
webdav_cli.py
Normal file → Executable file
10
webdav_cli.py
Normal file → Executable 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
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user