From 9a0a6ce0faa8cec53bf95d9be69e10177dfd2bcb Mon Sep 17 00:00:00 2001 From: retoor Date: Fri, 3 Oct 2025 04:28:54 +0200 Subject: [PATCH] Update. --- .gitignore | 5 ++ main.py | 158 +++++++++++++++++++++++++++++++++++++++++--------- webdav.db | Bin 57344 -> 0 bytes webdav_cli.py | 10 ++++ 4 files changed, 144 insertions(+), 29 deletions(-) delete mode 100644 webdav.db mode change 100644 => 100755 webdav_cli.py 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 eb38bc46b3ad1bcbebbc53a1e09e48b710930fd2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57344 zcmeI)Pfy!s90zc_frLQQ>@r!JhGc{^i27%CAV6A8YD!HNErd2CQrThFgZ%`oCUF`& z{80}TV{1=)6Fcm*S2Agbo%SAjoz(TXCy%j-F(Cz}9n!Bw65ILtdHj5S&+`~JDVxtq zmP_Tj-Dwh6UJyPLL{WGs%Yq;z+2a;__{#)a8S^*TtJt?bXf-L^`}spE{Xs~L{Vk;a zO#hMoCG~phuT(kt>(p-(iTGFJzmIRRx3E9}0ucB=1k6-i%FT+et(Hj-Eb~Bb(@xWJ z9LsJwde@~6=P?O<0bT2b&`5YFogcF+5z=xyTeX1<$>OR6Nk3hHav#*5Rc_}0xxE#aVE z1$@t$vX8%?Oh~h{;@g-@wi~psy?v`wCxurFk4jpSzMJqljbBmxS z$rWu)V@IxPn{rRUk>^?e8glUI*=Ei^K7^Y&{xX@6W@f}UCkYNZ*=Y}*45v4U_|LL? zspPdtlR9l;cxj7boSgTe-U}R-+8}t3G`fRIyl!s0ZSxX+Bb=TeV~K<$N#a||#~&Y} z{AnVT6u#@6eDwC~uKj|xJc+&`7nQqI8itNYuj*iU`D0;jzr%j*oQu}Z;~j&|l-{WZ z2hanf(RHl7^A0j;+uij?WiZ~9Xz8xiq;}WU9ctJu)A0|923|MW_I`_YcnhLsUt*Al zdqHL*E`1}30-xcXmklPUldfy?ZJj4ySNTiw2X{OnO;3wIJ>VV2q2A@qe~pK_jlcJt zo^$dRyP$nU&l%bouB%E9+7|1cq3Jd1YqfP^nk>#4kN|$3WQVrcEX^(v(Gsqlp|9g{ zX?1!~hJ0sT_16+l$KulM+v1j&B`>88ec7cggPvsA-y9F;EF3%Eo96DY(9CgJb!2>YY@j}V{FG_bEto5kDj*0I;Y{XSxSDiu~E_rUkdZEPolx+!!9J^3WLjMZ0%DFQnK*OdA6G!2$sYKmY;|fB*y_009U< z00Izzz|{qgz7VGbNj*xfF9=DAm`$t2pC0bhZIkTXWB+1D8u@JASXPx~MWxDup)M&z zAz4GoD=YPtY`$JM%q7*#)>TRrWr+|qS68TNF0m4FS#xn^Ilr7!w^t0Zk~3AhyiH8H zvbb&3mAqP~3nZVHAOHafKmY;|fB*y_009V$z5qV|AN?Gog%E%M1Rwwb2tWV=5P$##AOHb;{*OKY z0SG_<0uX=z1Rwwb2tWV=5Ey*{-2ab$j?qF0KmY;|fB*y_009U<00Izz0KWf^J^%p- zKmY;|fB*y_009U<00IygeF5D6kA9BPLI^+r0uX=z1Rwwb2tWV=5P$&g|Ir5^009U< U00Izz00bZa0SG_<0;4bRFXR